0. 들어가기 전에

 이 도움말 파일의 저작권은 진경소프트가 가집니다.
http://kepler.kyungpook.ac.kr
seojt@comeng.ce.kyungpook.ac.kr
seojt@chollian.net

0. 들어가기 전에

  이 책은 학원에서 혹은 학교에서 C++을 수강한 - 어떤 코드는 C로 작성된
것이지만 - 학생들이 C++기초의 다음과정으로서, 프로그래밍 능력을 기르는
데 도움이 되고자 만들었다. 이 책의 많은 부분은 자료구조(Data Structure)와
알고리즘(Algorithm)과 관계된 것은 사실이지만, 이 책이 자료구조나 알고리
즘의 내용을 설명한 책은 아니다.

  이 책은 C++의 교재는 아니지만 어떠한 부분은 C++의 논리적인 부분과 관
계된 것이 사실이다. 또한 이 책은 "클래스를 설계하는 법"을 설명하는 것이
아니고, "문제를 설계하는 법"에 대해 설명하고 있다. 여러분은 이 책의 내용
들을 따라 나감으로써 자연스럽게 주어진 문제의 클래스를 설계하는 법을 익
히게 될 것이다. 하지만, 책 안에서는 문제에 대해서는 자세히 설명하고 있지
만, 클래스에 대해서는 자세히 설명하고 있지 않다는 것을 주의해야 한다.
이 말이 클래스로 구현한 C++ 소스(Source)를 간과해도 된다는 의미는 아니
다. 오히려 여러분은 이 책에서 제시한 소스를 자세히 따라가며 사용된 기술
(Technique)과 설계 방법을 익힌다면, 문제를 해결하는 능력이 커지게 될 것
이다.

  소스를 작성하는데 필요한 언어는 신중히 택해야 한다. Borland C++ 4.x,
5.x 혹은 C++ Builder 3.x, 그리고 Visual C++ 5.x등을 두고 고민했는데,
Borland C++ 4.5로 대부분의 예제를 작성하고 실행하였다(하지만, 어떤 예는
C++ Builder에서 컴파일된 것도 있고, 큰 규모의 그래픽 프로그램은 Visual
C++와 GDI를 사용하였다). 하지만, 이 책의 내용 대부분은 특정 컴파일러와
관계된 것은 아니다. 하지만 그래픽과 관계된 부분은 BGI를 사용했으므로,
부득불 Borland C++ 계열의 컴파일러에서만 실행될 것이다. 혹 여러분이
Turbo C++ 3.x나 Borland C++ 3.x를 가지고 있더라고 전혀 문제되지 않는다.
Borland C++ 4.x이후의 버전들은 윈도우용의 기능들이 향상된 것이기 때문에
하위의 C++ 컴파일러와 기계어 레벨에서 호환된다(GDI를 사용하는 몇 예제
를 제외하고). 또한 이 책에서는 C++ 표준 라이브러리인 iostream 라이브러리
를 사용하지 않으려고 노력했다. 복잡한 그 라이브러리의 사용은 오히려 해가
된다고 판단했기 때문이다. 하지만, iostream에서 사용된 향상된 기교는 설명
과 함께 자세히 분석하였다.

  어떤 독자들은 GDI를 사용하지 않은 것이 불만일지 모른다. 하지만 소스를
간단히 하고 개념만을 설명하기 위한 방법으로 BGI가 최선의 선택이었다(이
책의 다음 판에서는 C++ Builder를 사용하여 C++ Builder와 이 책의 내용들
을 같이 설명할 것이다).

  이 책은 컴퓨터공학(Computer Engineering) 전공자를 위한 책이 아니며, 비
전공자들이 'C++이 아닌 프로그래밍 능력'을 기르는데 도움이 되도록 구성되
었으므로, 컴퓨터공학의 잡다한 대부분의 분야를 포함하고 있다. 이 말이 책을
만들기 위해 여기저기서 허드렛(Chore) 지식을 끓어 모았다는 것을 의미하는
것은 아니다. 오히려 반드시 필요한 정수(Core)만을 모으기 위해 노력했다.

  대부분의 독자들은 이 책의 여러 주제들에서 자신에게 필요한 개념들을 익
히게 될 것이다. 또 어떠한 독자들은 이 책의 어떤 한 가지 주제가 필요해서
이 책을 구입했을지도 모른다. 바로 그러한 독자들을 위해서 이 책의 내용들
을 정리하고 예제를 편집하였다.

  이 책은 반드시 순서대로 읽을 필요는 없다. 하지만 이 책의 대부분의 알고
리즘을 설명하기 위해 순서도(Flow Chart)보다는 상태변화도(State
Transition Diagram)를 사용하므로 2장의 오토마타(Automata)는 반드시 읽고
진행하기 바란다.

  몇년간 학원과 대학에서 비 전공자를 대상으로 프로그래밍 언어
(Programming Language)를 강의하면서, 비전공자임을 고려하여 난해한 개념
을 풀어서 해석하는 것보다, 전문적인 용어와 개념을 그대로 설명하는 것이
오히려 이해에 도움이 되는 것을 알게 되었다. 그래서 이 책은 대부분 형식적
인 정의(Formal Definition)를 소홀히 하지 않았다. 오히려 형식적으로 설명하
기 위해 노력했다. 또한 혼동되는 용어들은 괄호 안에 영문으로 같이 표기하
였으며, 어떤 단어들은 영문 발음 그대로를 사용하였다.

  먼저 알고리즘과 자료구조를 정의해 보자.

정의: 알고리즘은 특정한 작업(Task)을 완성하는 유한한 명령(Finite Set of
Instruction) 들의 모임이다. 알고리즘은 아래의 조건을 반드시 만족해야 한다.

  (1) 입력(Input): 외부적으로 제공되는 입력이 0개 이상이라야 한다.

  (2) 출력(Output): 최소한 한 개의 출력이 있어야 한다.

  (3) 명확(Definiteness): 각각의 명령은 분명하고(Clear) 모호하지 않아야 한
다.

  (4) 유한(Finiteness): 알고리즘을 한 줄 한 줄 추적(Trace)한다면, 모든 경
우에 유한개의 스텝(Step)만에 종료해야 한다.

  (5) 효과(Effectiveness): 모든 명령은 사람이 연필과 종이로도 할 수 있을
만큼 충분히 기본적(Basic)이어야 한다. (3)처럼 명확한 것만으로는 충분하지
않고, 반드시 구현가능(Feasible)하여야 한다.

<그림 ?> 알고리즘이 유한하지 않을 때의 우스운 그림

  자료구조(Data Structure)를 정의하기 전에 알고 있는 몇 가지 용어를 확인
해 보자.

  자료형(Data Type)은 프로그래밍 언어의 변수(Variable)가 가질(Hold) 수
있는 데이터의 종류를 말한다. char, int, float 등이 이러한 예들이다.

  자료객체(Data Object)란 자료형 요소(Element)들의 집합을 말한다. 예를
들어 정수형 자료 객체는 D={...,-2,-1,0,1,2,...} 이다.

  자료구조는 자료객체뿐만 아니라, 자료객체가 연관되어 있는 방법도 기술한
다는 점에서 자료객체와는 구분된다. 그러므로 자료구조라 하면,

  (1) 어떠한 크기의 데이터를 다루는가?

  (2) 그러한 데이터를 메모리에 어떻게 저장하는가?

  (3) 그러한 데이터를 조작하는 연산은 무엇인가?

<그림 ?> 자료구조에 함수가 포함되어 있는 그림

에 관한 것이라 할 수 있다. 그러므로 본 책에서는 자료구조를 설명하기 위해
서  C++의 클래스(Class)를 사용할 것이다. 자료구조가 클래스인 것은 아니지
만, 클래스는 자료구조를 설명하기 위한 충분조건임에는 틀림없다.

표기관례

  예약어(Reserved Word)가 특별히 강조될 때는 굵게(Bold) 표시하였으며,
강조하는 부분은 굵게, 기울게 혹은 굵고 기울게 표시하였다. 사용자가 입력
하거나 구현해야 하는 부분은 고딕체로 표시하였다. 언어의 소스(Source)나
예들은 고딕체를 사용하여 표시하였으며, 이외의 모든 사항들은 모두 일관성
을 유지하여 표기하였다. 또한 어떤 용어나 표기는 독자들이 알고 있다는 가
정 하에 참고서적만을 명시한 채 그대로 사용한 것도 있다.

예) int //예약어

    Enter a number: 13 //13을 사용자가 입력함

    type nFileOpened; //type은 예약어지만 사용자가 입력해야 함

    void main() {//프로그램 소스

        printf("Hello Wordl\n");

    }


0. 들어가기 전에

1. 스택(Stack)

  필자의 대학원때 별명은 한 때 택스(tax)였다. 아마도 그 당시 끝에 '스'를
붙이는 것이 유행이였는데, '진택스', '창태스', '병훈스' 처럼, 이름 뒤에 '스'를
붙였다. 하지만 필자는 유난히도 '택스'라고 불리웠는데, 세금을 의미하는 tax
와 LaTex등과 비슷한 발음으로, 부르기 편하기 때문이었을 것이다.

  그래서 스택은 필자에게 특별한 의미였다. 스택은 택스와 어떤 비슷한 면을
가지고 있다(?).

  스택은 큐(Queue)와 더불어 모든 알고리즘에 빈번히 사용되는 자료구조이
다. 스택은 스택이 보관하는 자료형과 자료를 넣는 푸시(Push), 자료를 꺼내
는 팝(Pop) 동작으로 구성된다. 스택 동작은 가장 나중에 푸시된 데이터가 가
장 먼저 팝되기 때문에 LIFO(Last In First Out)라고 한다■.

             큐(Queue)는 FIFO(First In First Out)라고 한다. 큐에 데이터를 넣는 동
          작을 삽입(Insert), 데이터를 빼내는 동작은 삭제(Delete)라고 한다. 삽입과
          삭제를 유지하기 위해 2개의 포인터를 유지하는데, 머리부분을 가리키는
          포인터를 머리(Head), 꼬리 부분을 가리키는 포인터를 꼬리(Tail)이라고 한
          다.

  스택은 적절한 푸시와 팝동작을 지원하기 위해 두 개의 포인터(Pointer)■

             포인터는 단순히 정수, 혹은 C++의 번지를 가리키는 변수라는 의미가 문
          맥에 따라 적절히 사용된다.

를 유지하는데, 스택의 밑바닥(Bottom)을 가리키는 Stack Bottom Pointer와
스택에 데이터가 들어갈 윗 부분(Top)을 가리키는 Stack Top Pointer가 있
다. 일반적으로 Stack Top Pointer를 SP(Stack Pointer)라고 하고, 구현 시에
Stack Bottom Pointer는 일반적으로 0이므로 무시한다.

  빈 스택의 상태는 Stack Bottom Pointer와 SP가 같은 곳을 가리킨다. 이
때를 스택 비어있음(Stack Empty)이라고 한다. 이때의 상태를 <그림 ?>에
나타내었다.

             스택의 초기 상태(Stack Empty)

  푸시는 SP가 가리키는 곳에 데이터를 넣고, SP를 1증가시킨다.

Stack[SP++]=data;

             푸시(Push)

  팝은 SP를 먼저 감소시키고, SP가 가리키는 곳의 데이터를 꺼낸다.

return Stack[--SP];

             팝(Pop)

  일반적으로 스택은 아래와 같이 구현한다.

class CStack {

    int sp;

    //데이터를 관리하기 위해, 자료형의 배열이나 링크드 리스트의 포인터

    //가 선언되어야 한다.

  public:

    CStack() {

        sp=0;

        //일반적으로 동적으로 메모리를 할당하는 문장이 명시된다.

    }

    ~CStack() {

        //동적으로 할당된 메모리를 해제하는 문장이 명시된다.

    }

    void Push(type data);

    type Pop();

};

  예를 들어 정수 스택에 관한 아래의 일련의 명령어들의 결과를 그림으로 나
타내보면 <그림 ?>와 같다.

    stack->Push(10);

    stack->Push(20);

    stack->Push(30);

    i=stack->Pop();//30이 팝된다.

    stack->Push(40);

    stack->Push(50);

    i=stack->Pop();//50이 팝된다.

             위 연산들 후의 스택의 최종 상태

  아래의 스택 틀(template) 소스가 여러분 자신의 스택을 구현하는데 많은
도움이 되었으면 한다. 아래의 코드부터는 템플릿을 사용하고 있으므로, 독자
들은 템플릿의 사용에 대해 알고 있어야 한다. '템플릿'을 참고하라.

#include <iostream.h>

enum BOOL {FALSE,TRUE};

template<class T> class CStack {

    T* base;

    T* sp;

    int size;

  public:

    CStack(int s) { base=sp=new T[size=s]; }

    ~CStack() { delete[] base; }

    void Push(T a) { *sp++=a; }

    BOOL Pop(T& a) {

        if (sp==base) return FALSE;

        else { a=*--sp; return TRUE; }

    }

    int Size() const { return sp-base; }

};//template<class T> class CStack

class CXYStack {

    CStack<int>* xstack;

    CStack<int>* ystack;

  public:

    CXYStack(int size) {

        xstack=new CStack<int>(size);

        ystack=new CStack<int>(size);

    }

    ~CXYStack() { delete xstack; delete ystack; }

    void Push(int x,int y) { xstack->Push(x); ystack->Push(y); }

    BOOL Pop(int& x,int& y) {

        if (xstack->Size()==0)

            return FALSE;

        else {

            xstack->Pop(x);

            ystack->Pop(y);

            return TRUE;

        }

    }

    int Size() { return xstack->Size(); }

};//class CXYStack

void main()

{

    CXYStack s(100);

    int x,y;

    s.Push(2,3);

    s.Push(4,5);

    s.Pop(x,y);

    cout << x << endl << y << endl;

}

  스택을 사용하는 몇 가지 프로그램을 통해 언제 스택을 이용하는지 알아보
도록 하자. 이러한 연습이 여러분 자신의 응용에 도움이 될 수 있도록, 잊지
말고 기억해 두기 바란다.

   위의 예에서 (x, y)를 저장하기 위한 CXYStack은 좀더 효율적인 구
조로 코드를 고칠 수 있다. 즉, CXYStack을 정의하지 않고도 (x, y)를 저장하
는 스택을 만들 수 있다. 어떻게 코드를 고쳐야 하는가?

   실습문제 4를 참조하라.

  십진수를 5진수로 바꾸는 프로그램

  아래의 <그림 ?>는 10진수 32를 5진수■로 바꾸는 과정을 보여준다. 5로

             일반적으로 n진수의 수 m인 경우, m(n)으로 나타낸다. 하지만 문맥상 진수
          가 확실한 경우 아래첨자 (n)을 생략하여, m으로 나타낸다. 우리가 사용하
          는 일반적인 진수체계는 10진수이므로, 아래첨자 (10)을 생략하는 것이 관
          례(Convention)이다. 하지만 시간(Time) 같은 경우는 2,3,4,6등으로 나누어지
          는 편리함으로 인하여 12진법을 사용하기도 한다.

나눈 나머지를 보관하였다가 더 이상 5로 나누어지지 않으면, 저장된 나머지
들을 거꾸로 출력하면 오진수가 된다. 결과를 거꾸로 출력하여야 하므로 스택
을 사용하여야 한다. 나머지를 차례대로 스택에 푸시하였다가, 더 이상 푸시가
일어나지 않으면, 차례대로 팝하면서 출력한다.

             10진수를 5진수로 바꾸는 과정

  아래에 10진수를 입력받아 5진수를 출력하는 프로그램의 소스를 리스트하였
다. 여기서 여러분이 주목하여야 하는 것은 10진수를 5진수로 바꾸는 것이 아
니라, '이러한 경우에는 스택을 사용하여야 한다는 것'을 아는 것이다.

#include <stdio.h>

enum BOOL {FALSE,TRUE};

template<class T> class CStack {

    T* base;

    T* sp;

    int size;

  public:

    CStack(int s) { base=sp=new T[size=s]; }

    ~CStack() { delete[] base; }

    void Push(T a) { *sp++=a; }

    BOOL Pop(T& a) {

        if (sp==base) return FALSE;

        else { a=*--sp; return TRUE; }

    }

    int Size() const { return sp-base; }

};//template<class T> class CStack

void main()

{

    CStack<int> s(100);

    int n;

    printf("Enter decimal digit:");

    scanf("%d",&n);

    while (n>=5) {

        s.Push(n%5);

        n/=5;

    }//while

    printf("Equivalent Quintett is ",n);

    while (s.Pop(n))

        printf("%d",n);

}//main

  실습문제

1. Infix로 표기된 수식■을 Postfix로 바꾸는 클래스를 설계하시오(A / B**C

             Ellis Horowitz, Sartaj Sahni, "Fundamentals of Data Structure in Pascal",
          Second Edition, COMPUTER SCIENCE PRESS, 1984, p.85.

+  D*E - A*C 의 Postfix 표현은 ABC**/DE*+AC*- 이다).

2. 연결된 요소(Connected Component)를 발견하는 문제: 2차원 평면에서
셀(Cell)들간의 관계는 인접한 이웃(Adjacent Neighbor)으로써 표현하는데, 4-
연결 이웃(4-Connected Neighbor: Von Neumann Neighbor)과 8-연결
이웃(8-Connected Neighbor: Moore Neighbor)■이 일반적이다. 한 셀에

             Jörg R. Weimar, "Simulation with Cellular Automata: Lecture Notes",
          Braunschweig, June 1996, p.17.

서의 인접한 이웃은 참조표(Look-up Table)를 사용해서 구현할 수 있지만,
연결된 요소를 찾는 문제는 참조표만으로는 구현이 어려우며, 스택을 사용하
면 구현이 가능하다.

             이웃

  <그림 ?>에서 셀에 딱지된 번호는 참조표의 인덱스를 의미하는데, 주의해
서 보기 바란다. 위와 같이 딱지를 매기면, 한 개의 참조표만으로 2가지 이웃
을 쉽게 참조할 수 있다.

  참조표를 아래와 같이 전역변수로 선언한 다음,

int offset[8][2]={{1,0},{0,-1},{-1,0},{0,1},{1,-1},{-1,-1},{-1,1},{1,1}};

  노이먼 이웃으로 이용할려면,

for (int i=0; i<4; ++i)

    if ((x+offset[i][0], y+offset[i][1]) is my condition) ...

  무어 이웃으로 이용할려면,

for (int i=0; i<8; ++i)

    if ((x+offset[i][0], y+offset[i][1]) is my condition) ...

처럼 i값만을 변경시켜 구현하면 된다.

  (1) 지뢰찾기(Mine Sweeper)■ 게임에서 연속한 빈칸을 발견하는 클래스

             지뢰 찾기는 Win95TM에 들어 있는 게임 프로그램이다.

를 설계하시오.

  왼쪽 그림에서 처럼, 사용자가 지뢰가 없는 빈 부분을
마우스로 클릭했을 때, 숫자가 있는 셀을 포함하여 빈
칸 모두를 표시해야 한다. 이러한 셀들은 4-연결 이웃
으로 구현하여야 한다.

  (2) 바둑에서 덩어리(String)를 발견하는 클래스를 설계하시오. 바둑에서 덩
어리라 하면, 같은 색 돌들의 4-연결 요소를 의미한다. 그림에서 검은색 덩어
리가 1로 딱지되어 있다.

3. 이진 이미지(Binary Image)에서 4-연결요소(4-Connected Component)■를

             Robert M. Haralick, Linda G. Shapiro, "Computer and Robot Vision
          VOLUME I", Addison Wesley, 1992, p.32.

발견하는 클래스를 설계하시오.

4. 위에서 제시된, 템플릿으로 설계된 스택 클래스 CStack에 1개 이상의 데이
터를 저장하기 위해서는 어떻게 CStack의 객체를 만들어야 하는가(힌트: 아래
의 코드를 참조하라)?

    struct MultipleData {

        int x, y, z;

    };//struct MultipleData

    CStack<MultipleData> s(100);

5.


2. 참조표(Look-up Table)

2. 참조표(Look-up Tabl
e)

  다음과 같은 얘기(fairy story)가 있다.

  대학 4학년생이 졸업할 때가 되었다. 최대의 관건은 (1) 직장은 구했느냐?
(2) 결혼할 여자는 있느냐? 이다. 유무에 따라 4가지로 구분해 보면,

  (1) 직장도 있고(=1), 애인도 있다(=1): 금메달

  (2) 직장은 있고(=1), 애인은 없다(=0): 은메달

  (3) 직장은 없지만(=0), 애인은 있다(=1): 동메달

  (4) 직장도 없고(=0), 애인도 없다(=0): 목메달

로 구분해 볼 수 있다. 아무래도 남자에게는 애인보다 직장이 우선인 모양이
다. 만약 여자 졸업생이라면 애인이 우선 순위일 수 있다. 그렇다고 경우 4에
해당하는 독자가 '목메달' 하기를 원하는 것은 아니다.

             목메달하기 직전의 남자 그림

  위와 같은 얘기를 프로그래밍 한다고 생각해 보자. 아마도 직장의 유무와
애인의 유무를 관리하기 위해 독자들은 2개의 변수를 선언할 것이다. 완성된
클래스는 다음과 같다.

#include <stdio.h>

class CAlumni {

    int bWork,bLover;

  public:

    CAlumni(int bWork,int bLover) {

        CAlumni::bWork=bWork;

        CAlumni::bLover=bLover;

    }//CAlumni

    void State();

};//class CAlumni

void CAlumni::State()

{

    int i;

    if (bWork==1 && bLover==1) i=0;

    else if (bWork==1 && bLover==0) i=1;

    else if (bWork==0 && bLover==1) i=2;

    else if (bWork==0 && bLover==0) i=3;

    if (i==0) printf("금메달\n");

    else if (i==1) printf("은메달\n");

    else if (i==2) printf("동메달\n");

    else if (i==3) printf("목메달\n");

}//CAlumni::State

void main()

{

    CAlumni kim(1,0);

    kim.State();

}//main

  아마도 여러분은 State() 메서드의 첫 번째 if 블락이 맘에 들지 않아 다음
과 같이 바꿀 수도 있을 것이다.

i=(bWork<<1) | bLover;

  두 번째 if 블락은 i를 인덱스로 하고, 출력할 문자열을 내용으로 하는 테이
블(Table)을 이용하면 간단하게 구현된다. 배열이 이러한 용도로 사용되었을
때, 참조표라고 한다(형식적인 정의에 익숙한 어떤 독자들은 이러한 정의가
마음에 들지 않을 지 모른다. 하지만 기억해 둘 것은 이 책은 비 전공자들의
프로그래밍 능력을 위해 쓰여진 책이란 것이다).

  바뀌어진 State() 메서드는 다음과 같다.

char sTable[][7]={"금메달","은메달","동메달","목메달"};//전역변수

void CAlumni::State()

{

    int i;

    i=(bWork<<1) | bLover;

    printf("%s\n",sTable[i]);

}//CAlumni::State

  일반적으로 미리 알려진 내용들을 이용할 때 이러한 참조표를 사용한다. 참
조표는 프로그램을 간단하고 보기 쉽게 한다.

  점수에 따라 등급을 출력하는 경우

  일선 학교의 교사들은 성적 처리 프로그램을 만드는 데, 등급을 출력하기를
원할지도 모른다.

점수대

등급

90~100

수

80~89

우

70~79

미

60~69

양

0~59

가

             등급표

  점수대와 등급 문자열이 <표 ?>와 같다고 할 때, 이를 참조표로 구현하면
아래의 소스와 같다. 배열의 인덱스를 구하기 위해 % 연산자의 기교
(Technique)을 사용했다는데 주의하기 바란다. 이러한 기교는 종종 그리고 유
용하게 사용된다.

    char *sTable[]={"가","가","가","가","가",

                     "가","양","미","우","수","수"};

    int i;

    scanf("%d",&i);

    printf("%s\n",sTable[i%10]);

기교(technique)

  잠시 배열의 인덱스 등을 계산하는데 사용하는 몇 가지 기교를 살펴보자.

  (1) 0과 1을 토글(Toggle)하는 경우: 프로그램에서 변수의 값을 0↔1을 토
글하기 위해서는 다음의 문장을 사용한다.

    int i=0;

    i^=1;//비트 배타합(eXclusive Or) 연산의 특징을 이용한다.

  이와 같은 기교는 임의의 두 수를 토글하는데 사용할 수 있다. 예를 들어 1
과 3을 토글하려면,

0001 ^ 0011 → 0010

이므로, 두 수의 배타합의 결과인 2와 배타합 연산을 하면된다.

    int i=1;

    i^=2;//1과 3을 토글한다.

  (2) 1과 -1을 토글하는 경우

    int i=1;

    i=-i;

  (3) 0,1,2,3을 토글하는 경우: 2개 보다 많은 숫자를 토글해야 하는 경우는
나머지 연산자 %를 사용해야 한다.

    int i=0;

    i=(i+1)%4;

  만약, 5,6,7,8을 토글하고 싶다면,

    int j=0, i;

    j=(j+1)%4;

    i=j+5;

처럼 사용한다. 일반적으로 m에서 시작하고 연속된 n개의 숫자를 토글하려면,

    int j=0, i;

    j=(j+1)%n;

    i=j+m;

을 사용하면 된다. 그러면 임의의 숫자열을 토글하는 방법은 없을까?

임의의 숫자열을 토글하려면

  토글을 원하는 숫자열이

100→34→200→79

처럼 규칙성이 없는 임의의 숫자들이라고 하자. 이러한 숫자열을 토글하는 방
법은 참조표를 이용하는 것이다. 이러한 값들을 참조표에 미리 넣어두고 배열
의 인덱스를 토글하면 간단히 구현할 수 있다.

    int toggle[]={100,34,200,79};

    int j=0, i;

    i=(i+1)%4;

    j=toggle[i];//얼마나 간단한가!

  임의의 사상(Mapping)을 구현

  이번에는 <그림 ?>와 같은 함수를 구현하는 문제를 고려해 보자. 사용자가
입력하는 값에 따라, 대응하는 치역(Range)의 값이 출력되어야 한다.

              어떤 사상

  이러한 것은 정의 구역(Domain)의 원소도 치역의 원소도 임의의 값에 해당
하므로, 2차원 참조표로 구현해야 한다.

  여러분이 직면하는 어떤 문제에 적절한 참조표가 생각날 수 있도록 하기 위
해서는 많은 연습과 경험이 필요하다■. 소스는 아래에 리스트하였다.

             필자의 경험으로 비추어 볼 때, 365시간 이상의 투자가 필요하다. 여러분
          이 C++ 프로그래밍에, 하루 1시간씩 1년여의 투자를 한다면, 독자들은 자
          신도 모르는 사이에 전문가가 되어 있을 것이다.

                 C++은 10대의  성(sex)과 같다. "1-모두의 마음속에 항상 존재한다.", "2-
          모두가 항상 이에 대해 이야기한다.", "3-다른 사람들은 모두 이에 대한 경
          험이 있다고 생각하지만, 실제로 경험한 사람은 거의 없다.", "4-극히 일부
          의 경험자들조차 제대로 시행해보지 못했으며, 또한 안전하게 시행하지도
          못했다.", "5-그러나 모두들 다음 번에는 잘 할 수 있으리라는 환상을 가지
          고 있다.", "6-모두가 자신들의 성공담을 자랑하고 다닌다. 실제로는 경험
          해 본적도 없으면서."(이스라엘의 어느  화장실 낙서에서)

#include <stdio.h>

int mapping[5][2]={{34,876},{125,56},{18,12},{345,11},{22,23}};

void main() {

    int i,n;

    scanf("%d",&n);

    for (i=0;i<5;++i)

        if (n==mapping[i][0]) {

            printf("%d\n",mapping[i][1]);

            break;

        }//if

}//main

  실습문제

1. 512와 1024를 토글하는 문장을 작성하시오.(힌트: 512 XOR 1024)

2. 변위(Offset)가 포함된 좌표의 검사에는 참조표를 사용하여야 한다. 아래
그림은 컴퓨터 바둑(Computer Go)에서 돌(Stone)들간의 관계를 고려하기
위해, 사용할 참조표이다. 예를 들면, 중앙의 검은 돌 입장에서, 8과는 한칸,
13과는 날일자(日)의 관계가 있다. 이를 참조표로 구현하라. 참조표의 인덱스
가 그림처럼 딱지되었다는 것에 주목하라. 인덱스가 그림처럼 딱지 되지 않으
면, 어떤 불편함 점이 발생하는지 고려해 보라■.

             실제로 눈목자(目) 등도 고려하여야 하므로, 참조표는 이보다 훨씬 크다.
          또한 변위가 (x,y)의 위치에서 2칸까지 변하므로, 판(Board)의 가장자리도 2
          보다는 크게 정해야지 구현이 쉽다. 판이 고려될 때 가장자리에 관한 문제
          는 뒤에서 상세히 다룬다.

              컴퓨터 바둑에서 돌들간의 거리

3. 한글 완성형 코드를 조합형 코드로 바꾸는 클래스를 설계하시오■.

             완성형과 조합형의 코드 값은 프린터 매뉴얼이나 한글 구현과 관련된 서
          적을 참고해야 알 수 있다.

4. 연결된 요소(Connected Component)를 발견하는 문제: 2차원 평면에서
셀(Cell)들간의 관계는 인접한 이웃(Adjacent Neighbor)으로써 표현하는데, 4-
연결 이웃(4-Connected Neighbor: Von Neumann Neighbor)과 8-연결
이웃(8-Connected Neighbor: Moore Neighbor)■이 일반적이다. 한 셀에

             Jörg R. Weimar, "Simulation with Cellular Automata: Lecture Notes",
          Braunschweig, June 1996, p.17.

서의 인접한 이웃은 참조표(Look-up Table)를 사용해서 구현할 수 있지만,
연결된 요소를 찾는 문제는 참조표만으로는 구현이 어려우며, 스택을 사용하
면 구현이 가능하다.

             이웃

  <그림 ?>에서 셀에 딱지된 번호는 참조표의 인덱스를 의미하는데, 주의해
서 보기 바란다. 위와 같이 딱지를 매기면, 한 개의 참조표만으로 2가지 이웃
을 쉽게 참조할 수 있다.

  참조표를 아래와 같이 전역변수로 선언한 다음,

int offset[8][2]={{1,0},{0,-1},{-1,0},{0,1},{1,-1},{-1,-1},{-1,1},{1,1}};

  노이먼 이웃으로 이용할려면,

for (int i=0; i<4; ++i)

    if ((x+offset[i][0], y+offset[i][1]) is my condition) ...

  무어 이웃으로 이용할려면,

for (int i=0; i<8; ++i)

    if ((x+offset[i][0], y+offset[i][1]) is my condition) ...

처럼 i값만을 변경시켜 구현하면 된다.

  (1) 지뢰찾기(Mine Sweeper)■ 게임에서 연속한 빈칸을 발견하는 클래스

             지뢰 찾기는 Win95TM에 들어 있는 게임 프로그램이다.

를 설계하시오.

  왼쪽 그림에서 처럼, 사용자가 지뢰가 없는 빈 부분을
마우스로 클릭했을 때, 숫자가 있는 셀을 포함하여 빈
칸 모두를 표시해야 한다. 이러한 셀들은 4-연결 이웃
으로 구현하여야 한다.


Untitled

3. 유한 오토마타(Finite
Automata)■

             A.K.Dewdney, "THE TURING OMNIBUS", Computer Science Press, 1989,
          p.8.

  산업, 군대 혹은 교육적인 환경에서 정확한 기능이 확실하지 않거나, 알려지
지 않은 전자 장비들을 보게 된다. 그 장치가 어떻게 동작하는지 확인하는 한
가지 방법은 그것을 조각으로 분리한 다음, 그 구성요소들을 분석하고 구성요
소들간의 상호동작을 관찰함으로서 기능을 유추하는 것이다. 그러나 이러한
것이 항상 가능한 것도 아니고 반드시 필요한 것도 아니다. 그 신비한 기계가
입력과 출력 기능을 가지고 있다면, 분리하지 않더라도 그 기계가 무엇을 하
는지 발견할 수 있다. 그 기계의 외관이 실제 기능에는 어떠한 단서도 주지
않기 때문에, 우리는 그 기계를 '검은 상자(Black Box)'라고 부른다.

  전기 잭을 꼽을 수 있는 Input 이라고 적힌 전기 터미널(Electrical
Terminal)과, ACCEPT 라고 적힌 전등(Lamp), 그리고 reset 이라고 적힌 버
튼을 가진 <그림 ?>와 같은 특정한 검은 상자를 생각해 보자. 이 기계는 2종
류의 전압을 입력으로 받아들일 수 있고 우리는 이것을 0과 1이라는 심벌
(Symbol)로 나타낸다고 하자. 우리는 이 검은 상자로 몇 가지 실험을 수행한
다. 처음 몇 개의 심벌들은 아무런 반응도 없다. 그러나 몇 개의 심벌들을 더
입력으로 받은 이 기계의 전등에 불이 들어온다. 우리가 입력한 심벌들의 열
(Sequence■)이 마침내 ACCEPT된 것이다! 조심스러운 몇 번의 실험 후에

             이 책에서는 혼돈을 피하기 위해 열 대신에 시퀀스란 말을 사용한다.

전등에 불이 들어오게 하는 시퀀스를 기록했다. 하지만 그 시퀀스를 2번 이상
반복하면 전등에 불이 들어오지 않는다. 실망한 우리들은 그 기계의 내부동작
이 아마도 제멋대로(Random) 동작하는 것이라고 상상하기 시작했다. 하지만
다른 누군가가 reset 버튼을 누른 다음 같은 시퀀스로 실험을 했더니 전등에
다시 불이 들어왔다. 이러한 실험을 몇 번 계속한 다음, reset 후에 전등에 불
이 들어오게 하는 시퀀스를 몇 개 더 발견했다. 우리는 이것에 주목했다. 그래
서 이 검은 상자의 동작을 분석하는 것을 얼마만큼 더 할 수 있을까 의아해
하기 시작했다.

             이상한 블랙 박스

  그러한 분석을 하기 위해서, 그 기계는 내부가 하나의 입력 심벌과 그 다음
심벌의 상태(State)를 구분하여 유지하도록 되어 있고, 그러한 상태가 유한
(Finite)하다는 가정을 해야 한다. 이 기계가 공장에서 생산된 것이 분명하다
면 이러한 유추는 매우 타당하다.

  이러한 방식으로 기술하였을 때 이 검은 상자는 본질적으로 유한 오토마타
■이다. 구체적인 용어를 사용하여 설명하면, 이 장치는 아래와 같이 구성된

             상태의 개수가 유한하다는 의미와, 입력심벌을 자동(Auto)으로 처리하여
          결과를 출력할 수 있다는 의미를 포함하고 있다.

다.

  (1) 유한개의 상태 모음 Q

  (2) 가능한 유한개의 입력 심벌 ㅓ

  (3) 현재의 상태에 주어진 입력 심벌을 보고 다음 상태를 결정하는 함수 Մ

  상태 Q중에 2가지는 특별한 경우이다. 하나는 시작상태(Initial State)이고
다른 하나는 종료상태(Accepting State;Final State)이다.

  Reset 버튼은 유한 오토마타의 내부 상태를 시작상태로 만든다. 0과 1의 특
정한 시퀀스는 오토마타를 종료상태로 만들며 이 때 전등에 불이 온다.

  유한 오토마타는 입력 시퀀스에 의해 내부 상태가 종료상태가 되면, 그 시
퀀스를 인식(Accept)한다고 한다. 그러한 모든 시퀀스들의 모임을 언어
(Language)라고 한다. 이러한 용어들을 가지고 다음과 같은 질문을 할 수 있
다.

  이 검은 상자가 인식하는 언어가 무엇인지를 구분하는 것이 가능한가? 간
명하게 전등에 불이 오게 만드는 모든 이진 시퀀스(Binary Sequence) - 단어
(Words) - 를 기술할 수 있는가?

  이 검은 상자의 내부 오토마타는 유한개의 상태만을 가지고 있으므로, 이러
한 작업이 비교적 쉽다고 생각할지도 모른다. 그러나 몇 번의 실험 후에 우리
는 의심에 가득 차게 되었다. 예를 들어, 아래의 단어들이 검은 상자에 의해서
인식되었다.

                             0101

                             0100101

                             0100100101

                             0100100100101

  이 오토마타가 01(001)*01을 인식한다는 것을 -(001)*는 001의 0번 이상의
반복을 의미한다 - 쉽게 발견할 수 있다. 사실상 오토마타의 일부를 <그림
?>처럼 상태변화도(State-Transition Diagram)로 그리는 것 - 상태는 번호가
매겨진 원으로, 상태변화는 화살표로, 시작상태는 짧은 화살표를 마킹
(Marking), 종료상태는 이중 원으로 - 이 가능하다.■

             상태변화도에서 ?는 죽은 상태(Dead State)를 의미한다. 이 상태에 빠지
          게 되면 어떤 입력이 들어와도 죽은 상태를 빠져 나오지 못한다. 상태변화
          도를 그릴 때 일반적으로 죽은 상태는 그리지 않는다.

             가능한 상태변화도

 그림에서 상태변화는 함수 Մ를 나타낸다. 예를 들어 오토마타의 현재상태가
3일 때, Մ는 입력으로 1이 들어오면 다음상태를 4, 0이 들어오면 5로 결정한
다.

  하지만 이 변화도가 정확하다는 것을 어떻게 알 수 있는가? 불행하게도 알
수 있는 방법은 없다. 같은 결과를 얻을 수 있는 다른 변화도를 - 예를 들면
<그림 ?> 같은 - 상상하는 것은 무척 쉽다■.

             죽은 상태는 그리지 않았다.

             가능한 다른 상태 변화도

  실제로는 이 검은 상자가 01(001)*01을 인식한다는 우리의 추측 자체에 의
심이 간다. 001이 98번 반복하는 것은 인식하지만, 99번 반복하는 것은 인식하
지 않을 지 알게 뭔가!

  이 상황에서 한 유능한 직원이 검은 상자의 뒷면에서 다음과 같은 딱지
(Label)를 발견했다.

GENERAL ELECTRIC

Black Box 3718-45X

(6 states)

  이러한 정보가 있다면, 이 오토마타가 인식하는 언어가 무엇인지 결정하는
것이 가능해진다. 어떤 이론에서는 - 정규 언어(Regular Language)의 펌핑
(Pumping) 렘마(Lemma) - n=0,1,2,3,4,5에 대해, 오토마타가 01(001)n01을 인
식한다면, 01(001)*01에 속하는 모든 단어를 인식한다고 한다. 그러므로 다음
과 같이 쓸 수 있다.

01(001)*01 ⊆ LA

  LA는 검은 상자 안에 있는 오토마타 A가 인식하는 언어를 의미한다.

  가능성에 대한 다른 체계적인 다른 실험이 다음과 같은 추가적인 결과를 얻
었다고 하자.

(0110)*111 ⊆ LA

  그러면 아래와 같이 쓸 수 있다.

01(001)*01 + (0110)*111 ⊆ LA

  이것은 오토마타 A가 첫 번째와 두 번째 형태 모두를 인식한다는 것을 의
미한다.

  그러한 시퀀스에 대한 가능성이 유한하기 때문에 그 상자에 있는 오토마타
의 완벽한 표현을 얻어낼 수 있다. 즉 구조를 알 수 있을 뿐만 아니라, 타당한
입력 시퀀스에 대해 어떻게 응답하는지 알 수 있다.

  유한 오토마타에 의해 인식되는 언어는 항상 정규 표현(Regular
Expression)으로 표현할 수 있다. 정규 표현은 아래의 규칙들을 적용하여 얻
어 진다.

  (1) 0 과 1은 정규 표현이다.

  (2) X와 Y가 정규 표현일 때 접속(Concatenation) XY와 합(Sum) X + Y -
어떤 사람은 X | Y로 나타낸다 - 역시 정규 표현이다.

  (3) X가 정규 표현이면 반복(Iteration) X*도 정규 표현이다.■

             독자들은 여기서 MS-DOSTM가 사용하는 '거친 문자(Wild Card)' - *와 ?
          - 가 정규 표현이라는 것을 알게 될 것이다. DOS의 *는

            (a + b + ... + y + z + A + ... + Z + '몇 가지 특수문자')*

                의 약식(short) 표현이다. 예를 들면 (a + ... + Z)*.com 은 *.com으로 나타낸
          다. 다른 넓은 문자 ? 는 (a + b + ... + y + z + A + ... + Z + '몇 가지 특
          수문자')를 나타낸다. 그러므로 ? 자리에는 반드시 한 개의 문자가 와야 한
          다.

  게다가, 모든 정규 표현에 대해, 표현되는 언어를 인식하는 유한 오토마타가
존재한다. 이와 같이 정규 표현은 오토마타가 인식하는 언어를 표현한다.

  그러나 각각의 정규 표현에 대해 이를 인식하는 유한 오토마타는 무한히 존
재한다. 그러므로 오토마타에 의해 인식되는 언어를 정확히 알고 있더라도 그
상자의 정확한 내부 구조는 - 상태 수와 연결 등 - 상자를 부셔, 내부를 들여
다보는  수밖에 없다.

  유한 오토마타는 넓게 연구되어온 가장 간단한 계산 모델(Computational
Model)의 한가지이다. 그러므로 쉽게 알 수 없는 제한점이 존재한다. 예를 들
면, 어떤 유한 오토마타도 주어진 알파벳에 대해 '구분된 대칭문자열(Marked
Palindromes)'은 인식하지 못한다. z가 주어진 알파벳의 특정 단어를 나타내
고, m은 알파벳에는 없는 특수 심벌을 나타내며, z-1은 z의 역스트링(Reverse
String)을 나타낼 때, zmz-1 를 인식하는 유한 오토마타는 존재하지 않는다■.

             이러한 대칭 문자열은 '문맥 자유 문법(Context Free Grammar: CFG)'
          에 의해서 인식된다.

  언제 오토마타를 이용하는가?

  알고리즘을 표현하는 방법으로 흔히 순서도를 생각하지만, 오토마타의 상태
변화도는 알고리즘을 표현하는 매우 강력한 수단이다.

  조언은 다음과 같다.

문제가 주어졌을 때, 다음의 사항들을 생각해 본다.

  (1) 문제를 풀이하는 과정이 구분할 수 있는 상태를 가지고 있는가?

  (2) 정의된 어떤 상태에서 다음 입력에 의해 상태를 바꾸는 것이 구현 가능
한가?

위의 조건들이 만족되면 그 문제를 상태변화도로 그리는 것이 가능하며 또한
구현 가능하다.

  상태변화도는 다음과 같이 그린다. 상태는 원안에 번호로 나타내고, 입력 및
상태변화에 의한 출력은 화살표에 딱지로 나타낸다. 또한 상태에 도착했을 때
의 입력 및 출력은 원의 딱지로 나타낸다. 시작 상태는 짧은 화살표 딱지를
붙이고, 종료 상태는 이중원(Double Circle)으로 나타낸다. 딱지는 a / b의 형
태로 쓰는데 입력이 a이고 출력은 b라는 것을 의미한다.

  상태변화도를 그리는 것은 체계적인 방법이 없다. 경험에 의한 휴리스틱
(Heuristic)에 의존하므로, 많은 연습을 통해서 익숙해 져야 한다.

  <그림 ?>와 같은 상태변화도를 완성했을 때, 이를 구현하는 방법에 대해서
알아보자. 구현하는 방법 역시 형식적인 어떤 방법이 존재하는 것은 아니지만,
여기서 제시된 방법은 독자들 자신만의 휴리스틱을 개발하는 데 많은 도움이
될 것이다. 이 그림의 오토마타가 어떤 언어를 인식하는지, 무엇을 하는지 알
려고 할 필요는 없다. 오토마타가 주어졌을 때 이를 어떻게 구현하는지에 집
중해 주기 바란다.

             구현을 설명하기 위해 제작된 오토마타

  먼저, 상태 유지와 입력에 필요한 변수를 선언하고, 상태에 도달했을 때 해
야할 일들을 함수로 작성한다. 상태 유지를 위해 state, 입력 변수는 input, 오
토마타가 사용하는 변수 s, 상태 2에 도달했을 때 해야 할 일들은
PrintOnState2()로 구현했다.

#include <stdio.h>

#include <conio.h>

#include <string.h>

void PrintOnState2(char *s)

{

    printf("%s\n",s);

    printf("terminate\n");

}//PrintOnState2

void main()

{

    int state,input=0;

    char s[80]={0,};//NULL 문자열로 초기화한다.

}//main

  문제에 맞는 종료조건을 정의한 다음, while 루프와 switch 문장을 사용하
여 상태변화를 구현한다. 루프에 들어가기 전에 입력 값을 적당히 초기화하고
시작상태를 초기화한다. 오토마타에 명시되지 않은 입력이 들어온 경우 탈출
상태를 정의한 다음 while 루프의 전체의 탈출 조건을 명시한다. 루프를 탈출
한 후에 종료상태에서 종료한 경우 해야할 일들을 while 루프 다음에 명시한
다.

#include <stdio.h>

#include <conio.h>

#include <string.h>

void PrintOnState2(char *s)

{

    printf("%s\n",s);

    printf("terminate\n");

}//PrintOnState2

void main()

{

    int state,input=0;

    char s[80]={0,};

    state=0;//시작상태는 0이다.

    while (input!=27 && state!=99) {//ESC가 입력되거나 Dead State가

                                       //되면 종료한다.

        input=getch();

        switch (state) {

            case 0:

                break;

            case 1:

                break;

            case 2:

        }//switch

    }//while

    if (state==2)//2번은 종료상태이다.

        printf("Input recognized\n");

}//main

  각각의 상태에 대해 가능한 모든 입력에 대해 switch 문의 case를 완성한
다. 예를 들면 상태 0인 경우 'a'가 입력으로 들어오면,

                              s=s+'a';

                              print "start";

에 해당하는 문장을 C++로 다음과 같이 코딩한다.

                              strcat(s,"a");

                              printf("%s\n",s);

  완성된 코드는 다음과 같다.

#include <stdio.h>

#include <conio.h>

#include <string.h>

void PrintOnState2(char *s)

{

    printf("%s\n",s);

    printf("terminate\n");

}//PrintOnState2

void main()

{

    int state,input=0;

    char s[80]={0,};

    state=0;

    while (input!=27 && state!=99) {

        input=getch();

        switch (state) {

            case 0:

                if (input=='a') {

                    strcat(s,"a");

                    printf("start\n");

                    state=1;

                } else

                    state=99;//Dead State를 99로 정의하였다.

                break;

            case 1:

                if (input=='b') {

                    strcat(s,"b");

                    state=1;

                } else if (input=='c') {

                    strcat(s,"c");

                    state=2;

                    PrintOnState2(s);

                } else

                    state=99;

                break;

            case 2:

                PrintOnState2(s);//상태에 도착할 때마다 실행된다.

                if (input=='d') {

                    printf("restart\n");

                    state=0;

                } else

                    state=99;

        }//switch

    }//while

    if (state==2)

        printf("Input recognized\n");

}//main

  한 가지의 추가적인 예를 통해 오토마타를 설계하는 방법을 연습해 보자.

  HWPTM의 선그리기 오토마타

  한글에서 Alt+D를 누르면 자동으로 선을 그리는 기능을 제공한다. 물론 이
것은 선이 아니라 선처럼 보이는 특수문자를 자동으로 나열하는 것에 불과하
다. 이 기능을 유한 오토마타로 구현해 보자.

  완성된 상태변화도는 아래와 같다. 예를 들면 0번 RIGHT 상태는 사용자가
오른쪽 방향키를 눌러 선이 오른쪽으로 그려지고 있는 상태를 의미한다. 이
때 사용자가 위쪽 방향키를 누르면 상태는 1번 UP상태로 바뀐다. 이때 화살
표에 딱지된 특수문자를 출력한다. 이 오토마타는 이미 그려진 선은 고려하지
않는다. 여러분이 그 부분까지 완성해 보기 바란다.

             자동으로 연결된 선을 그리는 오토마타

  위 오토마타의 프로그램 소스는 아래에 리스트(List) 하였다. 입력에 따라
출력되는 그래픽 특수문자를 효과적으로 처리하기 위해 입력에 대해 switch
문을 사용 - 위의 예에서는 상태에 대해 switch 문을 사용하였음 - 하였으며,
룩업 테이블을 사용하였다.

#include <stdio.h>

#include <conio.h>

#include <dos.h>

#define ESC      27

#define UP       72

#define LEFT     75

#define RIGHT    77

#define DOWN     80

void putchxy(int x,int y,int c)

{

    gotoxy(x,y);

    putch(c);

    gotoxy(x,y);

}//putchxy

void main(void)

{

    int x=40,y=12,key=0,state=0;

    char line[4][4]={"+|+|","+|+|","-+-+","-+-+"};// + - +|+ +

    // 이러한 특수문자는 Alt 키를 누른 상태에서 아스키코드를 십진수로 입
력

    //한다.

    clrscr();

    while (key!=ESC) {

        key=getch();

        if (key==0) key=getch();

        switch (key) {

            case UP:

                putchxy(x,y,line[0][state]);

                state=1, --y;

                break;

            case DOWN:

                putchxy(x,y,line[1][state]);

                state=3, ++y;

                break;

            case LEFT:

                putchxy(x,y,line[2][state]);

                state=2, --x;

                break;

            case RIGHT:

                putchxy(x,y,line[3][state]);

                state=0, ++x;

                break;

        }//switch

    }//while

}//main

  실습문제

1. 아래와 같은 언어를 인식하는 유한 오토마타 클래스를 설계하시오.

  (1) (ab)*(a+b)*

  (2) 0* + 1

2. 탁상용 계산기 프로그램의 상태변화도가 아래와 같을 때 계산기 프로그램
을 설계하시오.

3. 한글 입력 루틴은 유한 오토마타로 설계 가능하다. 한글 입력 오토마타를
설계하시오.

4. 한글/영문의 입력 모드를 자동으로 변환하는 기능 - HWP (주) 한글과 컴
퓨터에서 처음으로 상용(commercial version) 발표 - 은 유한 오토마타로 설
계 가능하다. 이를 설계하시오■.

             유승목, 이정훈, 김삼묘, "한/영 키 모드 자동 전환기", 정보과학회 가을
          학술 발표 논문집(A), vol. 22(2), pp.589-592, 1995.

5. 게임트리(Game Tree)는 유한 오토마타로 구현 가능하다. 이를 설계하시오
■.

             서진택, 이종철, "동작적 효율을 개선한 게임트리 구현", 한국정보과학회
          봄 학술발표논문집 제22권 1호, 1995, pp.155-158.

6. 스트링 매칭은 유한 오토마타로 설계 가능하다. 이를 설계하라■.

             Thomas H. Cormen, et al., "Introduction to Algorithm," McGrow-Hill Book
          Company, 1992, p.862.


4. 혼돈과 프랙탈(Chaos and Fractal)

4. 혼돈과 프랙탈(Chaos
and Fractal)

  프랑스의 수학자 만델브로트(B. Mandelbrot)는 1967년 영국에서 발행되는
과학 잡지 [사이언스]에 느닷없이 '섬나라 영국을 둘러싸고 있는 해안선의 총
길이는 얼마인가'라는 제목의 글을 발표했다. 고도로 발달한 현대 과학이나 수
학에서 보면 초보적인 질문이기 때문에 다소 엉뚱해 보인 이 글에서, 만델브
로트는 영국의 해안선 길이는 어떤 자로 재느냐에 따라 얼마든지 달라질 수
있다고 주장했다. 1m짜리 자로 쟀을 때의 해안선 길이와 1km 단위의 자로
쟀을 때의 해안선 길이가 당연히 차이가 나듯이 만약 개미가 자기 몸에 줄자
를 달고 해안선을 돌아 길이를 잰다면 1m 단위의 자로 잰 것과는 엄청난 차
이를 낳을 것이라고 강조했다. 길이를 재는 자의 단위가 작을수록 해안선의
둘레는 그만큼 길어질 것이라는 주장이었다. 해안선의 길이는 재는 자의 길이
에 달려 있다는 것이었다. 당시엔 별 주목을 끌지 못한 만델브로트의 이 글은
오래지 않아 '프랙탈 이론'의 선구적 논문으로 지목되어 많은 과학자들로 하
여금 부랴부랴 해묵은 [사이언스]지를 뒤적이게 만들었다.

  카오스 이론, 혼돈이론, 프랙탈 이론 등으로 불리는 이 최신 이론은 최근
들어 과학자 사회에서 지지 기반을 광범위하게 넓혀 가면서 열성적인 지지자
들에 의해 상대성 이론, 양자역학과 함께 현대 과학의 3대 혁명 중의 하나로
추대되고 있다. 이전의 과학은 미시 세계, 거시 세계의 규칙성에만 주의를 기
울여 왔으나 이제는 복잡성의 세계가 새로운 과학의 주제로 떠올랐다는 것이
다.

  카오스 이론과 프랙탈 이론은 결국은 같은 말인데 굳이 엄밀하게 구분해 보
자면 겉으로는 불규칙해 보이는 현상들 가운데서도 자세히 관찰해 보면 어떤
규칙성을 찾을 수 있다는 것이 카오스(혼돈)이고 그 혼돈된 상태를 공간적인
구조로, 즉 기하학적이고 규칙적으로 표현한 것이 프랙탈이다. 프랙탈은 카오
스를 기술하는 언어인 셈이다.

  카오스 이론 하면 일반인들이 가장 먼저 떠올리는 것이 '나비 효과' 라는
애교스러운 이름일 것 같다. 오늘 서울 하늘에서 나비 한 마리가 날개 짓을
한 것이 며칠 뒤엔 뉴욕의 날씨에 영향을 끼쳐 큰 바람을 일으킬 수 있다는,
사람들의 귀를 솔깃하게 하긴 하지만 다소 과장이 섞인 이야기 말이다. 아무
튼 카오스 이론은 이처럼 겉으로 보기엔 불규칙적이고 제멋대로이기 때문에
종래의 물리학이나 수학에서 제쳐놓았던 분야들에 관심을 갖는 것에서 출발한
다.

  여태까지의 물리학이나 생물학은 기상의 변화, 동물의 번식, 난류의 흐름,
커피에 탄 프림이 어떤 운동을 하는지 등에 대해 한정적으로만 다루었다. 예
컨대 일기 예보는 기압, 바람, 온도 정도의 적은 수의 변수만 따져서 하루나
이틀 뒤, 혹은 장기적인 기상 변화를 예측하기 때문에 정확도가 떨어질뿐더러
기상 변화의 원인을 정확히 설명하지 못한다. 마찬가지로 어느 숲에서 송충이
가 번식하는데 어느 해엔 번식력이 왕성했다가 그 다음해엔 완만해진다든지,
인구 증감이 어떤 규칙성을 띠고 있는지 등에 대해 종전까지의 생물학이나 인
구학에서는 변화를 제대로 설명하지 못하거나 전망하지 못했다. 카오스 이론
에서는 복잡한 자연 현상에는 수많은 변화 요인이 있기 때문에 서너 가지의
변수만으로 그것을 설명하려는 것 자체가 무리라고 주장한다. 일식이나 월식,
혜성의 주기, 조수의 간만 등은 뉴턴 역학에서처럼 몇 가지의 변수만 가지고
도 설명이 가능하다. 즉 태양과 지구의 인력, 행성과 행성간의 인력 등만 고려
하면 되는 것이다. 그 밖의 요인들은 이들의 운동에 영향을 끼치지 않을 정도
로 사소하기 때문에 무시해도 된다고 본다. 카오스 이론은 이 '사소한 요인'들
에 주목한다. 이제까지의 일기 예보가 나비의 운동과 같은 사소한 것을 소홀
히 해 왔다면 카오스 이론에서는 그것이야말로 변화를 일으키는 동력이 될 수
있다고 주장하는 것이다. 따라서 이들은 사소한 변화에 민감한 현상들에 주의
를 모은다.

  만델브로트의 '해안선의 측정'의 예에서 보여 주듯이 우리는 상식적으로 해
안선의 길이를 측정하는 데 아주 미세한 굴곡들은 무시해도 별 상관이 없을
것처럼 생각한다. 그러나 실제로는 그 미세한 굴곡을 어떻게 처리하느냐에 따
라 하늘과 땅만큼의 차이가 난다는 것을 확인할 수 있는 것이다. 그러면 과연
해안선의 길이는 얼마까지 세밀하게 측정될 수 있는가? 1m의 자로 잰다고 하
더라도 다시 50cm 단위의 자로 재면 길이가 더 늘어날 것이고 다시 10cm의
자로 재면 또 늘어나고... 한없이 밀고 나갈 수가 있을 것이다. 결국 무한한
길이가 된다는 말인가?

<그림 ?> 코흐(Koch) 곡선

  코흐 곡선이라는 것이 있다. <그림 ?>에서 제일 위에 있는 선분을 삼등분
한 다음 중앙의 1/3 지점에 모양은 같고 한 변의 길이는 1/3인 새 삼각형을
붙인다. 그리고 두 번째의 그림에서 각각의 선분을 다시 삼등분하여 위의 과
정을 반복한다. 이 과정을 계속해 나가면 코흐 곡선이 얻어지는데 그림에서
보듯이 각각의 부분은 그 부분이 빠져 나온 전체와 닮아 있다. 이와 같은 자
기 유사(Self Similarity) 도형을 프랙탈이라고 한다. 프랙탈은 부분을 보면
전체의 모양을 알 수 있고 전체를 보면 부분의 모양을 알 수 있다. 프랙탈 이
론에서는 앞에서 얘기했던 기상의 변화라든가 생태계의 변화, 난류의 흐름 등
겉보기에 복잡한 현상들에서 이 같은 자기유사의 도형이 발견된다고 주장한
다. 이제 나비 효과라는 말이 왜 나오는지 이해가 갈 것이다. 부분이 전체와
연결되어 있기 때문에 기상의 변화라는 전체의 관점에서 보면 나비의 날개짓
이라는 일부분에서의 아주 미세한 변화도 매 단계마다 그 변화가 증폭되어 나
중에는 엄청난 크기로 전체에 전달된다고 해석하는 것이다.

  카오스 이론을 따른다면 어떤 현상이 자기 유사의 도형을 갖는다는 것이 확
인되면 그러한 복잡계를 만드는 가장 기본적이고 출발점이 되는 도형만 찾으
면 문제는 단순해진다. 코흐 곡선을 예로 들면 선분의 삼등분이라는 핵심만
잡게 되면 복잡하게 보이는 도형도 수월하게 해체할 수 있는 것이다.

  여기까지의 이야기에서 눈치 챘겠지만 카오스 이론은 혼돈스럽고 복잡하고
애매한 모든 현상에 적용할 수 있는 것이 아니다. 하나의 복잡한 구조나 현상
이 주어졌을 때 그 일부분을 확대해 전체와 비교해 보아 이 둘 사이에 비슷한
구조가 보여야지만 카오스 이론을 적용할 수가 있다. 100% 일치하는 자기 유
사는 아니더라도 통계적으로는 유사함을 보여야 한다. 카오스(혼돈) 속에서
질서를 찾는 이론이라고 하는 이유도 여기에 있다. 카오스 이론의 등장으로
규칙적인 현상이나 운동을 다루는 고전 물리학이 무용해졌다든지 서양 이성의
몰락을 읽는 것은 지나친 비약이다. 카오스 이론은 서구 이성의 확장을 요구
할망정 기존 과학의 유산을 부정하는 것은 전혀 아니기 때문이다.

차원(Dimension)

  비록 프랙탈의 형태는 가지각색이지만, 프랙탈마다 "닮은꼴들의 비율이 얼
마이고 개수는 몇 개인가"를 따져 볼 수 있다. 이러한 특성은 경우에 따라 달
라지는데, 코흐 곡선의 경우에는 크기가 1/3인 부분들이 4개 모여 전체를 이
루고 있다.

  프랙탈 도형의 차원은 이러한 차이를 나타내기 위해 사용되는 개념이다. 그
러나 프랙탈 차원의 값은 정수가 아니다. 프랙탈 곡선은 직선과 평면의 중간
적인 성격을 가지고 있으므로 1에서 2사이의 차원을, 프랙탈 곡면은 평면과
입체의 중간 성격으로서 2에서 3사이의 차원을 가진다. 예를 들면, 코흐 곡선
은 1.26차원이다.

  이러한 것은 <그림 ?>에서처럼 직선으로 이루어진 곡선이지만, 직선의 길
이를 구할 수 없고 - 1차원이 아니다 -, 또한 평면을 어느 정도 채우는 것 같
지만 면적은 여전히 0 - 2차원이 아니다 - 이다.

  비록 이러한 프랙탈 차원이 낯설기는 하지만, 이것은 우리가 일반적으로 사
용하는 차원의 의미를 넓힌 데 불과하다. <그림 ?>를 보면서 차원의 의미를
생각해 보자.

<그림 ?> 차원

  (1) 1차원 직선을 비율 r의 닮은 꼴들로 나누면, 그 개수는 N=1/r이다.

  (2) 2차원 평면을 비율 r의 닮은 꼴들로 나누면, 그 개수는 N=1/r2이다.

  (3) 3차원 입방체를 비율 r의 닮은 꼴들로 나누면, 그 개수는 N=1/r3이다.

  위의 3가지 경우에서 유추해 보면, 물체의 차원이 D일 때, 조각들의 비율 r
과 조각들의 개수 N의 관계는 다음과 같다.

N*rD=1

그러므로,

D= { logN}
over {log1/r
}

여기서,

    N: 자신을 닮은 작은 부분들의 개수

    r: 작은 부분이 전체에서 차지하는 비율

이다. 1에 가까운 차원을 가지는 곡선은 대략 직선에 가까운 부드러운 형태이
며, 차원의 값이 2에 접근할수록 곡선은 점점 심한 굴곡을 나타내면서 평면상
의 많은 곳을 "누비고" 다니며 평면을 채우게 된다. 곡선의 차원이 2가되면,
평면을 가득 메우게 되는데, 이를 공간 채움 곡선(Space Filling Curve)이
라 한다.

  코흐 곡선의 경우는 크기가 1/3인 부분이 4개 있으므로,

N=4, r=1/3

이다. 그러므로,

D=log4/log3=1.26

차원이다.

  코흐 곡선을 표준 VGA 모드에서 화면에 꽉 차게, 1번만 그린다면 직선 1
개의 길이는 얼마가 되어야 하는가? 640*1/3이 되어야 한다. 2단계까지 그리
면, 640*(1/3)2이 되어야 한다. 그러므로 n단계의 코흐 곡선을 화면에 꽉차게
그리려면 길이는,

640*(1/3)n

이 되어야 한다. 완성된 코흐 클래스는 아래에 리스트하였다.

#include <stdio.h>

#include <conio.h>

#include <math.h>

#include <graphics.h>

inline double ToRadian(int degree) {

    return degree*M_PI/180;

}//ToRadian

class CKoch {

    int X_START,Y_START;//starting position of curve

    double xCurrent,yCurrent,//current x,y

           step;//draw line step

    int degree,//current drawig degree

        nOrder;//order

  private:

    void Forward() {

        xCurrent+=step*cos(::ToRadian(degree));

        yCurrent+=step*sin(::ToRadian(degree));

        lineto(xCurrent+X_START,-yCurrent+Y_START);

    }//Forward

    void Turn(int degreeStep) {

        degree=(degree+degreeStep)%360;

    }//Turn

  public:

    CKoch(int n,int x=0, int y=240) {

        nOrder=n; degree=xCurrent=yCurrent=0;

        X_START=x; Y_START=y;

        step=640*pow(1.0/3.0, nOrder);

        moveto(xCurrent+X_START,-yCurrent+Y_START);

    }//CKoch

    void Draw(int level);

    void Koch() { Draw(1); }

};//class CKoch

void CKoch::Draw(int level)

{

    if (level>nOrder)

        Forward();

    else {

        Draw(level+1);

        Turn(60);

        Draw(level+1);

        Turn(-120);

        Draw(level+1);

        Turn(60);

        Draw(level+1);

    }//if.. else..

}//CKoch::Draw

void main()

{

    int gd=0, gm;

    initgraph( &gd, &gm, "..\\bgi" );

    CKoch k(3);

    k.Koch();

    getch();

    closegraph();

}//main

  아래의 프랙탈 곡선은 두개의 재귀 함수가 서로를 호출하는 경우이다.

Sierpinski 곡선

  Sierpinski 곡선을 그리기 위해서는, 두개의 함수 Zig와 Zag를 사용한다. 모
래사장을 움직이는 거북(Turtle)을 생각해 보자. 이 거북이 어느 방향을 향
하고 있든지, 거북이 Zig와 Zag명령을 받았을 때, 다음과 같이 행동한다.

  Zig: 왼쪽으로 90°회전한다. d만큼 전진한다.

       한번 더 왼쪽으로 90°회전한다. d만큼 전진한다.

  Zag: 오른쪽으로 90°회전한다. d만큼 전진한다.

       한번 더 오른쪽으로 90°회전한다. d만큼 전진한다.

       왼쪽으로 회전한다. d만큼 전진한다.

  d는 변수이므로 프로그램의 인자(Argument)로 사용된다. Zig와 Zag의 또
다른 인자 n은 재귀 호출을 언제 끝낼지를 제어한다. Zig와 Zag는 서로를 사
용하여 다음과 같이 정의된다.

Zig(n): if n=1 then turn left      Zag(n): if n=1 then turn right

                    advance 1 unit                    advance 1 unit

                    turn left                         turn right

                    advance 1 unit                    advance 1 unit

        else                                          turn left

                    Zig(n/2)                          advance 1 unit

                    Zag(n/2)               else

                    Zig(n/2)                          Zag(n/2)

                    Zag(n/2)                          Zag(n/2)

                                                      Zig(n/2)

                                                      Zag(n/2)

  위의 두 함수가 만들어 졌다면 main()에서 다음을 적어주기만 하면 된다.

Zig(8);

Zag(8);

  아래의 그림은 인자 n에 따른 결과를 보여준다.

<그림 ?> n에 따른 Sierpinski 곡선의 변화

  Zig(1)과 Zig(2)가 어떻게 동작하는지 <그림 ?>에 표시하였다.

<그림 ?> Zig(1)과 Zig(2)의 동작

  <그림 ?>는 아래에 리스트된 프로그램의 실행결과이다.

<그림 ?> Sierpinski 곡선

#include <stdio.h>

#include <conio.h>

#include <dos.h>

#include <graphics.h>

enum DIR {

    DIR_RIGHT,

    DIR_UP,

    DIR_LEFT,

    DIR_DOWN

};//enum DIR

class CSierpinski {

    int dir,cx,cy;

    static int STEP[4][2];

  private:

    void Turn(int d);

    void Advance();

  public:

    CSierpinski(int d=DIR_UP,int x=639,int y=0) {

        dir=d; cx=x; cy=y;

        moveto(cx,cy);

    }//CSierpinski

    void Zig(int n);

    void Zag(int n);

};//class CSierpinski

int CSierpinski::STEP[4][2]={{10,0},{0,-10},{-10,0},{0,10}};

void CSierpinski::Turn(int d)

{

    if (d==DIR_LEFT) dir=(dir+1)%4;

    else if (d==DIR_RIGHT) dir=(dir+3)%4;

}//CSierpinski::Turn

void CSierpinski::Advance()

{

    cx+=STEP[dir][0];

    cy+=STEP[dir][1];

    lineto(cx,cy);

    delay(10);

}//CSierpinski::Advance

void CSierpinski::Zig(int n)

{

    if (n==1) {

        Turn(DIR_LEFT);

        Advance();

        Turn(DIR_LEFT);

        Advance();

    } else {

        Zig(n/2);

        Zag(n/2);

        Zig(n/2);

        Zag(n/2);

    }//if.. else..

}//CSierpinski::Zig

void CSierpinski::Zag(int n)

{

    if (n==1) {

        Turn(DIR_RIGHT);

        Advance();

        Turn(DIR_RIGHT);

        Advance();

        Turn(DIR_LEFT);

        Advance();

    } else {

        Zag(n/2);

        Zag(n/2);

        Zig(n/2);

        Zag(n/2);

    }//if.. else..

}//CSierpinski::Zag

void main()

{

    int gd=DETECT,gm;

    initgraph(&gd,&gm,"C:\\BORLANDC\\BGI");

    CSierpinski s;

    s.Zig(16);

    s.Zig(16);

    getch();

    closegraph();

}

전혀 쓰이는 곳이 없을 것 같은데...

  임의 프랙탈(Random Fractal)과 IFS(Iterated Function System) 등은
물리학의 열역학 제 2법칙인 엔트로피(Entropy) 증가의 법칙을 모의 실험하
는데 뿐만 아니라, 자연계의 물체인 산, 바다, 구름 등을 모의 실험하는데 다
양하게 사용된다. 짧은 코드로 복잡한 자연 물체를 나타낼 수 있기 때문에 컴
퓨터 게임 및 컴퓨터 그래픽 등에서도 그 응용분야는 상당히 넓다.

<그림 ?> IFS를 사용하여 나타낸 나뭇잎

<그림 ?> 임의 프랙탈을 사용하여 나타낸 계곡

실습문제

1. 위에서 구현한 Sierpinski 곡선은 <그림 ?>처럼 가장자리를 둥글게 처리하
지 못한다. 가장자리를 <그림 ?>처럼 처리하도록 프로그램을 수정하라.

2.


5. 클래스와 클래스의 계층을 설계하는 법

5. 클래스와 클래스의 계
층을 설계하는 법■

             Patrick Henry Winston, "ON TO C++", Addison Wesley, 1994, pp.83-85.

  독자들이 이미 클래스를 정의하는 문법에는 익숙해 있을 것이다. 여기서는
클래스를 설계하는 관례와 몇 가지 추천되는 규칙들을 살펴본다.

  여기서 제시되는 규칙들은 관례이므로 반드시 그렇게 할 필요는 없지만, 전
문가 - Patrick Henry Winston은 MIT 컴퓨터 과학과의 교수로 인공지능 등
여러 분야의 전문서적을 출판하였다 - 의 조언을 쉽게 보아 넘기지 말기 바
란다.

  외적 표현의 원리(Explicit-representation principle): 프로그램이 작업할
때 필요한 어떤 범주(Category)에 해당하는 클래스가 실제로 있어야 한다.

  만약 원(Circle)을 그리기 위한 프로그램을 설계한다고 생각해 보자. 여러분
이 설계하는 C++ 프로그램에는 당연히 CCircle 이라는 클래스가 있어야 할
것이다. 당연한 이야기 같지만 초보자들이 흔히 저지르는 실수중 대부분이 외
적 표현의 원리를 무시한다.

  중복 불가의 원리(NO-duplication principle): 멤버 변수나 멤버 함수 등
은 여러 클래스에 잘 분배되어서 똑같은 코드가 중복되지 않도록 설계해야 한
다.

  CCircle 클래스를 상속받은 CEllipse를 설계할 때, 상위 클래스에서 정의된,
같은 기능의 멤버 함수나 멤버변수를 CEllipse에서 또 정의해서는 안된다. 클
래스 설계자는 중복 불가의 원리가 성립하도록 멤버함수나 멤버변수의 접근
변경자(Access Modifier)를 잘 정의해야 한다.

  지역 관점의 원리(Local-view principle): 관련된 프로그램 요소들은 프로
그램 소스 내에서 되도록 가깝게 위치해야 한다. 그래서 그런 요소들을 한 눈
에 확인할 수 있어야 한다.

  이와 같은 원리는 프로그램을 유지, 보수 할 때 흔히 무시된다. 후에 프로그
램의 요소를 추가하더라도 관련된 곳에 위치하도록 설계자는 신경을 써야 한
다.

             지역 관점의 원리

  보기만 하기의 원리(Look-it-up principle): 자주 필요로 되는 값은 매번
계산하기 보다 계산된 결과를 즉시 이용할 수 있어야 한다.

  CCircle의 원의 둘레를 구하는 멤버함수 GetCircumference()를 생각해 보자.
이 함수가 빈번히 호출된다면, 매번 부동소수점 계산을 하는 것보다, 원의 둘
레를 멤버변수로 선언하는 것이 바람직하다.

  알 필요성의 원리(Need-to-know principle): 다른 누군가가 사용할 클래
스를 설계할 때, 외부에서 접근할 수 있는 멤버 변수나 함수보다, 공개되지 않
은 멤버변수나 함수가 많아야 한다. Public된 것만 공개함으로써 클래스를 수
정해야 할 필요가 생길 때, 외부 인터페이스는 유지한 채, 클래스의 기능을 개
선시킬 수 있다.

  이와 같은 원리는 C++ 및 객체 지향 언어의 특징인 추상화(abstraction), 캡
슐화(encapsulation), 다형성(polymorphism), 상속성(inheritance) 중 추상화와
캡슐화를 설명한다. 추상화에 의하면 클래스의 자세한 내부 사항은 몰라도 클
래스 사용자는 그 클래스를 사용할 수 있다. 캡슐화에 의하면 클래스 설계자
는 클래스의 자세한 사항은 숨기고 공개하고 싶은 멤버만을 공개해야 한다.

  간단히 유지의 원리(Keep-it-simple principle): 복잡한 프로그램 요소를
가진 컴퓨터는 코딩, 디버깅, 개선과 유지가 모두 힘들다. 따라서 클래스 정의
는 너무 복잡해서 - 일반적으로 20줄이 넘어가는 클래스 정의 - 이해하기 어
려워서는 안 된다. 그러한 클래스는 스코프 해결사를 사용하여, 멤버 함수 등
을 클래스의 외부에서 정의하는 것을 고려해 볼 만하다. 이러한 규칙은 일반
함수를 정의할 때도 마찬가지이다. 복잡한 함수를 정의할 때 함수의 기능을
구분해서 디버깅과 유지가 독립적으로 될 수 있도록 함수를 분할해야 한다.

  20줄이 넘어가는 코드는 원래의 프로그래머의 설명이 없다면 이해하기가 힘
들다고 한다. 같은 일을 하는 코드가 여러 번 호출된다면 함수로 만들어서 효
율을 높이는 것이 당연하다. 이 원리는 그것을 말하는 것이 아니다. 비록 한
번 밖에 호출되지 않더라도 프로그램을 간단히 유지하기 위해서는 그것을 함
수로 만들라는 것이다.

  모듈화의 원리(Modularity principle): 큰 프로그램을 논리적으로 시종 일
관한(Coherent)■ 모듈 - 각각이 하나의 파일을 차지하는 - 로 나누어야 한

             응집(Cohesion)은 마이어즈(Myers)가 1978년 자신의 논문에서 모듈안에서
          의 상호관계(Interaction)의 척도로써 제시하였으며, 7단계의 응집을 정의하
          였다. 또한 모듈간의 상호관계의 척도로써 짝짓기(Coupling)을 제시하였다.
          자세한 사항은 Stephen R. Schach가 지은 "Software Engineering", Aksen
          Associates, 1990 을 참조하라.

다. 클래스와 함수를 나누는 것이 첫 번째 단계이고, 클래스의 계층 구조를 따
라 클래스들을 나누는 것이 두 번째 단계이다.

  일반적으로 하나의 클래스는 두개의 파일 - 헤더 파일과 클래스 본체 파일
- 로 설계한다. 이 작업이 쓸데없는 것 같지만, 프로그램의 문서화와 유지, 보
수에 큰 도움이 된다.

  컴퓨터 알고리즘(computer algorithm)

요리사(cook) 이야기

  유명한 요리사가 되고자하는 2명의 사람이 있었다. 그 중 한 명은 이 세상
에 존재하는 모든 요리책을 독파하기로 했다. 그래서 그 사람은 이 세상에 존
재하는 모든 요리를 만드는 법을 외울 수 있게 되었다.

  또 다른 한 사람은 요리는 무조건 만들어 보기로 했다. 처음 접하는 요리를
할 때는 정보를 얻기 위해 책을 조금 보았을 뿐, 그 사람은 책을 보지는 않았
다. 마침내 그 사람은 몇 가지 요리를 이 세상에서 제일 맛있게 만들 수 있게
되었다.

  어떤 사람은 자신이 요리책의 내용을 굉장히 많이 알고 있다고 해서, 요리
를 굉장히 잘 하는 사람의 작업을 무시한다.

  어떤 사람은 자신이 굉장히 요리를 맛있게 만들 수 있다고 해서, 요리책의
내용을 굉장히 많이 아는 사람을 무시한다.

  이 책이 '요리책의 내용'과 '요리하는 법'을 모두 설명하는 교재가 되기를 기
도한다.

  컴퓨터 공학을 전공하는 학생들이 배우는 요리책에는 다음과 같은 몇 가지
요리 만드는 방법이 있다. 물론 이 방법이 이 세상의 모든 요리를 만들 수는
없지만, 대부분의 요리를 만드는 방법을 알려주고 있다.

  필자는 개인적으로 '실제로 요리하기를 좋아하는 사람'으로써 이러한 요리책
을 쓴 알고리즘의 대가(greate person)를 무척 존경한다. 기우(imagined fear)
가 있다면, 요리책을 읽고 나서 꼭 요리를 해 보라는 것이다.

    ■ 나누어서 정복하기(divide-and-conquer)

    ■ 탐욕스러운 방법(the greedy method)

    ■ 동적 프로그래밍(dynamic programming)

    ■ 탐색(search)

    ■ 역행(backtracking)

    ■ 갈라져서 제한하기(branch-and-bound)

    ■ 유전자 알고리즘(genetic algorithm)

  요리법

■ 나누어서 정복하기(divide-and-conquer)

  n개의 입력을 계산하는 함수가 주어졌을 때, divide-and-conquer 전략은
입력을 k개의 서로 다른(distinct) 부분집합(subset)으로 나눈다. 여기서 1 < k
≤ n 이며, k개의 작은 문제(subproblem)가 발생한다. 이러한 작은 문제를 풀
이할 수 있어야하며, 작은 문제들을 결합(combine)해서 전체의 해를 구할 수
있도록 작은 문제들을 결합할 수 있는 방법들을 찾아야한다. 작은 문제가 여
전히 크다면, divide-and-conquer 전략이 다시 적용될 수 있다. 일반적으로
divide-and-conquer에 의해서 생긴 작은 문제는, 원래의 문제와 유형이 같다.
그러한 경우 divide-and-conquer를 다시 적용하는 것은 재귀호출(recursion)에
의해 자연스럽게 구현할 수 있다. 이제 같은 종류의 점점 더 작은 문제들이
생성되며, 결국은 더 이상 나누지 않고도 풀 수 있을 정도로 작은 문제로 나
뉘어 진다.

  함수의 모양은 다음과 같이 될 것이다.

solution_type DivideAndConquer(int start, int end) {

    if (ProblemIsSmallEnough(start,end))

        return TrivialSolution(start,end);

    else {

        int m;

        m=Divide(start,end);

        return Combine(

            DivideAndConquer(start,m),

            DivideAndConquer(m+1,end));

    }

}

             Divide-and-conquer: 문제의 크기가 작다면 풀이 가능하다. 어려운 문제를
          풀이 가능할 때까지 같은 유형의 작은 문제로 나눈다. 풀이된 작은 문제를
          결합하여 원래의 문제를 풀이한다.

  Divide-and-conquer로 풀이할 수 있는 알려진 문제로는 다음과 같은 것이
있다. 이러한 문제는 일반적인 자료구조(data structure)와 알고리즘
(algorithm) 교재에서 쉽게 접할 수 있으므로, 그러한 교재를 참고하기 바란
다.

    ① 이진 탐색(binary search)

    ② maximum과 minimum을 발견하는 문제

    ③ 병합 정렬(merge sorting)

    ④ 빠른 정렬(quick sorting)

    ⑤ Selection(n개의 입력중 k번째로 작은 원소를 찾는 문제)

    ⑥ Strassen의 행렬 곱하기

■ 탐욕스러운 방법(the greedy method)

  Greedy 방법(method)은 아마도 가장 직관적인 기교이며, 더군다나 다양한
종류의 문제들을 풀이하는데 사용될 수 있다. 전부는 아니지만 대부분의 이러
한 문제들은 n개의 입력을 받아 어떠한 제약 조건(constraint)을 만족하는 부
분집합(subset)을 얻는 것을 필요로 한다. 이러한 제약 조건을 만족하는 부분
집합을 가능한 해(feasible solution)라고 한다.

             Greedy 방법: 처음에 해집합은 공집합에서 시작한다. 가능한 해를 구하기
          위해, 지금까지 구한 해에서 해집합에 포함 가능한 나머지 이웃을 본다.
          가능하다면, 해집합에 포함시킨다.

  Greedy 방법으로 풀이할 수 있는 알려진 문제로는 다음과 같은 것이 있다.

    ① 테이프상의 최적 공간(optimal storage) 문제

    ② 배낭 문제(knapsack problem)

    ③ 마감시간을 가지는 일 순서(job sequence with deadline) 문제

    ④ 최적 병합 패턴(optimal merge pattern) 문제

    ⑤ 최소 신장 트리(minimum spanning tree) 문제

    ⑥ 한 시작점 가장 짧은 경로(single source shortest path) 문제

■ 동적 프로그래밍(dynamic programming)

  동적 프로그래밍은 Greedy 방법과 비슷하다. 하지만, 분명히 다르다.

    ■ Greedy 방법은 지금까지 구한 가능한 해(feasible solution) 집합을

    제외한, 입력의 나머지 부분에서 가능한(feasible) 해를 찾는다. 최종

    의 해는 점점(greedy) 자라난다. 반면에, 동적 프로그래밍은 최종 해

    를 찾기 위해, 지금 까지 구한 임시의 결과를 이용한다. 즉, Greedy

    방법은 이웃을 보지만, 동적 프로그래밍은 자신을 본다.

             탐욕과 동적(dynamic): 탐욕은 남을 살피지만, 동적은 자신을 살핀다.

    ■ Greedy 방법은 가능한 해만을 보지만, 동적 프로그래밍은 지금

    까지 구한 임시 결과 - 가장 최근에 구한 - 를 모두 본다.

  동적 프로그래밍으로 풀이할 수 있는 알려진 문제로는 다음과 같은 것이 있
다.

    ① 다단식 그래프(multistage graph) 문제

    ② 모든 쌍 가장 짧은 경로(all pairs shortest paths) 문제

    ③ 최적 이진 탐색 트리(optimal binary search tree) 문제

    ④ 0/1 배낭(0/1 knapsack) 문제

    ⑤ 신뢰할만한 디자인(reliability design)

    ⑥ 여행하는 세일즈맨(traveling salesman) 문제

    ⑦ Flow shop scheduling

■ 탐색(search)

  탐색으로 풀이할 수 있는 알려진 문제로는 다음과 같은 것이 있다.

    ① 코드 최적화(code optimization)

    ② AND/OR 그래프

    ③ 게임 트리(game tree)

    ④ 이중 연결 요소(biconnected components)

    ⑤ 깊이 우선 탐색(depth first search)

■ 역행(backtracking)

  역행으로 풀이할 수 있는 알려진 문제로는 다음과 같은 것이 있다.

    ① 8-여왕 문제(8-queens problem)

    ② 부분 집합의 합(sum of subsets)

    ③ 그래프 색칠(graph coloring)

    ④ Hamiltonian 순환(cycle)

    ⑤ 배낭 문제(knapsack problem)

■ 갈라져서 제한하기(branch-and-bound)

  갈라져서 제한하기로 풀이할 수 있는 알려진 문제로는 다음과 같은 것이 있
다.

    ① 0/1 배낭 문제

    ② 여행하는 세일즈맨 문제

■ 유전자 알고리즘(genetic algorithm)

  '유전자 알고리즘'을 참조하라.

■ 풀이는 할 수 있으나 시간이 굉장히 많이 걸리는
문제: NP-Hard 혹은 NP-Complete 문제

■ 풀이할 수 없다고 생각되는 문제

  실습문제

1. 유한 오토마타(finite automata)에 대해서 상술하라.

2. 문맥 자유 문법(context free grammar)에 대해서 상술하라.

3. 튜링 머신(Turing machine)에 대해서 상술하라.


6. 재귀 호출(Recursive Call)

6. 재귀 호출(Recursive
Call)

  굳이 재귀 호출을 사용하는 이유는 재귀적인 함수 정의가 인간의 자연적인
사고를 표현하기에 적합한 경우가 많기 때문이다. 2의 멱승(Power) 2n - n은
자연수 - 을 구하는 함수를 통해 재귀 호출이 어떻게 동작하는지 살펴보도록
하자. 우리가 구현할 함수는 RecursivePowerOf2()이다. 먼저 재귀 호출을 사
용하지 않는 PowerOf2()를 정의해 보자. 코드는 다음과 같다.

int PowerOf2(int n) {

    int counter,result=1;

    for (counter=n; counter; --counter)

        result*=2;

    return result;

}

  PowerOf2()가 존재하면, 첫 번째 재귀 버전을 다음과 같이 구한다. 물론 이
것은 재귀호출이 아니지만, 우리가 앞으로 점차적으로 바꾸어 나갈 재귀버전
의 첫 번째 단계이다.

int RecursivePowerOf2(int n) {

    return PowerOf2(n);

}

  여러분이 첫 번째 주목해야 할 것은 파라미터가 0인 경우는 굳이 함수 호출
이 필요 없다는 - 함수 호출은 내부적으로 스택을 사용하고 Prefix와 Postfi
x 코드의 생성과 보존에 관계된 오버헤드가 크다 - 것이다.

int RecursivePowerOf2(int n) {

    if (n==0)

        return 1;

    else

        return PowerOf2(n);

}

  다음 단계로, RecursivePowerOf2()가 좀 더 많은 부담을 가짐으로써
PowerOf2()의 부담을 조금 들어 줄 수 있다. 이것은 PowerOf2()의 파라미터
에 1을 빼주고, 이의 결과에 2를 곱함으로써 가능하다.

int RecursivePowerOf2(int n) {

    if (n==0)

        return 1;

    else

        return 2*PowerOf2(n-1);

}

  위의 문장이 2의 멱승을 구하는 것은 틀림이 없다. 이제 마침내 재귀 호출
의 마지막 버전을 작성할 수 있게 되었다. 이제 재귀 호출의 마술을 사용해
보자. 이 작업은 놀랄 정도로 간단하지만 이해하기는 무척 어렵다.
PowerOf2(n-1)을 RecursivePowerOf2(n-1)로 교체한다.

int RecursivePowerOf2(int n) {

    if (n==0)

        return 1;

    else

        return 2*RecursivePowerOf2(n-1);

}

  위와 같이 함수의 정의에 자신을 부르는 코드가 포함되었을 때, 이 함수는
재귀적(Recursive)이다고 한다. 재귀적인 함수는 재귀 함수(Recursive
Function)이다. 여러분이 위의 함수를 가지고 ResursivePowerOf2(4)를 호출
했을 때, 컴파일러는 스택을 사용하여, 일련의 코드를 생성한다. 첫 번째 호출
에서 컴파일러는 임시의 결과를 스택에 푸시한다. 스택에 푸시되는 정보는 임
시 결과이므로, 불완전한 결과가 나중에 완성되어야 한다는 정보도 함께 들어
간다.

<그림 ?> 2*RecursivePowerOf2(3)이 푸시된 스택의 상태

  이러한 과정은 계속되다가 첫 번째 if문을 만족하는 경우에 마침내 종료한
다.

<그림 ?> 푸시가 중단된 스택의 상태

 기억해야 할 것은 이때는 푸시만 중단된 것이다. 이제 결과를 산출하기 위해,
컴파일러가 자동으로 생성한 코드는 스택의 결과를 거슬러 올라가는데 이때를
역추적(Backtracking)이라고 한다. 역추적은 첫 번째 푸쉬된 데이터를 만
나기 전까지 계속된다. <그림 ?>에서 화살표에 딱지된 번호는 역추적의 순서
를 의미한다.

<그림 ?> 스택의 데이터를 역추적하는 그림

  여러분이 재귀 호출에 익숙해지는 방법은 아마도 재귀 정의(Recursive
Definition)에 익숙해지는 것이다. 실제로 재귀 정의를 C++의 재귀 호출로 만
드는 것은 놀라울 정도로 간단하다. 재귀 정의는 3개의 조건을 가진다.

  (1) 기본 조항(Base Part) : 재귀 조건을 종료하게 만드는 간단한(Simple) 문
장이 명시된다.

  (2) 재귀 조항(Recursive Part) : 재귀 조건을 명시한다.

  (3) 한계 조항(Limitation Part) : (1), (2)이외에는 정의에 포함되지 않음을 명
시한다.

  일반적으로 한계 조항은 명시하지 않아도 명시된 것으로 한다.

  위와 같은 재귀 정의가 주어졌을 때, 이것을 재귀 함수로 구현하는 것은 쉽
다. 기본 조항을 첫 번째 if문으로, 재귀 조항을 두 번째 if문으로 구현하면 된
다. 모든 재귀 정의는 재귀 함수로 구현할 수 있다. 하지만 모든 재귀 함수가
재귀 정의인 것은 아니다. 위와 같은 재귀 정의는 다음과 같은 재귀 함수로
구현한다.

type Recursive(typelist) {

    if (Base Condition)

        return Base Statement;

    else if (Recursive Condition)

        return Recursive Statement;

}

  그렇다면 계승(Factorial) n!를 재귀 정의해 보자. 계승은 다음과 같이 정의
할 수 있다.

    기본 조항: 0!=1

    재귀 조항: n!=n*(n-1)!  단, n≥1

    [한계 조항: 위의 정의 이외의 것은 계승이 아니다.]

  구현한 계승함수는 다음과 같다.

int Factorial(int n) {

    if (n==0)

        return 1;

    else

        return n*Factorial(n-1);

}

  피보나치(Fibonacci) 수열

  <그림 ?>의 (衁)과 같은 정사각형의 비단을 (遁)과 같은 직사각형의 비단
으로 만드는 것은 쉽다. 그림을 잘 보면, 어떻게 비단을 구성하는지 알 것이
다. 바느질만 잘 한다면, 전혀 감쪽같이 직사각형의 비단을 만드는 것이 가능
하다. 독자 여러분들은 여기서 잠시 멈추어 아래의 그림에서 오류를 찾아보기
바란다.

<그림 ?> 비단의 파라독스(Paradox)

  (衁)의 정사각형 비단의 면적은,

13*13=169

하지만, (遁)의 직사각형 비단의 면적은,

21*8=168

이다! 어디서 잘못 되었는가?

  위의 이야기는 피보나치 수열의 파라독스를 이야기할 때 흔히 듣는 이야기
로써 긴 대각선을 따라 난, 자그마한 틈을 계산에 넣지 않았기 때문에 발생한
다.

  피보나치 수열은 황금비(Golden Ratio)를 발견하게 된 수열로써 유명하다.
피보나치 수열은 아래와 같다.

  (1) F0=F1=a

  (2) Fn=Fn-1+Fn-2  단, n≥2

  만약 초항 a가 1이라면, 수열은 다음과 같다.

1, 1, 2, 3, 5, 8, 13, 21, 34, ...

  위와 같은 수열에서는 재미있는 사실이 있는데, a, b, c를 수열의 임의의 연
속한 세 숫자라하면,

  (1) a+b=c

  (2) b2=ac±1

이 성립한다. 초항을 어떻게 정하느냐에 따라 (2)번식의 상수 항(Term)이 달
라지는데, 이 상수항을 0으로 만드는 초항을 황금비라 한다. (2)번식의 c를
(1)번 식으로 치환한 다음, a를 상수항으로보고, b에 관한 2차방정식을 풀이하
면,

b={(1+ SQRT
{ 5} ) } over
{ 2} a

이다. 초항이 (1+㇒5)/2면 b2=ac가 된다. 초항이 황금비인 숫자들의 열로써 위
의 비단의 파라독스를 만들 수 없다. 아마도 그래서 황금비라 한 것 같다. 피
보나치 수열의 n번째 항을 구하는 함수는 다음과 같다.

long Fibonacci(long n) {

    if (n==0 || n==1)

        return 1;

    else

        return Finonacci(n-1)+Fibonacci(n-2);

}

  하노이 탑(Tower of Hanoi)

  재귀 호출을 가르칠 때 흔히 예로 드는 문제중의 하나가 바로 하노이 탑 문
제이다. 3개의 막대기가 있으며, 여섯 장의 디스크(Disk)가 가장 왼쪽 막대기
에 크기 순서대로 쌓여져 있다. 이 디스크들을 아래의 규칙들을 만족하면서,
가장 오른쪽 막대기로 모두 옮기는 것이 문제를 해결하는 것이다.

  (1) 한번에 하나씩의 디스크만을 옮길 수 있다.

  (2) 반드시 작은 디스크를 큰 디스크 위로 옮길 수 있다.

<그림 ?> 하노이 탑

<그림 ?> 디스크를 옮기는 원리

void Hanoi(int numberOfDisk, int source, int destination, int via) {

    if (numberOfDisk > 0) {

        Hanoi(numberOfDisk-1, source, via, destination);

        printf("Move %d to %d\n", source, destination);

        Hanoi(numberOfDisk-1, via, destination, source);

    }

}

  그래피컬 하게 구현한 소스는 아래에 리스트하였다.

//#include <iostream.h>

#include <conio.h>

#include <graphics.h>

#include <process.h>

#include <dos.h>

//#define TRACE

#define MAX_HANOI_STAIR     20

#define DISC_HEIGHT         10

#define KEY_ESC             27

#define HANOI_STAIR         5

class CTower {

    int n,pos;

    int data[MAX_HANOI_STAIR];

  public:

    CTower(int i,int p);

    void Insert(int s) { data[n]=s; ++n; }

    int Delete() { --n; return data[n]; }

    void Draw();

};

CTower::CTower(int j,int p)

{

    n=j; pos=p;

    for (int i=0;i<n;++i)

        data[i]=n-i;

}//CTower

void CTower::Draw()

{

    int dx=getmaxx()/3,sx,sy=getmaxy()-n*DISC_HEIGHT;

    sx=dx/2+dx*pos;

    line(sx,getmaxy()-HANOI_STAIR*DISC_HEIGHT,sx,getmaxy());

    for (int i=1;i<=n;++i)

        rectangle(sx-DISC_HEIGHT*data[n-i],sy+(i-1)*DISC_HEIGHT,

                  sx+DISC_HEIGHT*data[n-i],sy+(i)*DISC_HEIGHT);

}//CTower::Draw

CTower c[3]={CTower(HANOI_STAIR,0),CTower(0,1),CTower(0,2)};

int p=0;//page number

void DrawHanoi()

{

    setactivepage(p^1);

    setvisualpage(p);

    bar(0,0,getmaxx(),getmaxy());

    c[0].Draw();c[1].Draw();c[2].Draw();

    setvisualpage(p^1);

    setactivepage(p);

    p^=1;

  #ifdef TRACE

    if (KEY_ESC==getch()) exit(0);

  #else

    delay(500);

    if (kbhit()) exit(0);

  #endif

}//DrawHanoi

void Hanoi(int from,int to,int aux,int nDisc)

{

    int i;

    if (nDisc==1) {

        //cout << from << " -> " << to << endl;

        i=c[from].Delete(); c[to].Insert(i); DrawHanoi();

    } else {

        Hanoi(from,aux,to,nDisc-1);

        //cout << from << " -> " << to << endl;

        i=c[from].Delete(); c[to].Insert(i); DrawHanoi();

        Hanoi(aux,to,from,nDisc-1);

    }//if.. else..

}//hanoi

void main()

{

    int gd=VGA,gm=VGAMED;

    initgraph(&gd,&gm,"f:\\borlandc\\bgi");

    /* BGI의 경로를 적절하게 변경해야 한다. 일반적으로 C:\\Borlandc\\BGI

       이다. */

    setfillstyle(SOLID_FILL,BLACK);

    Hanoi(0,2,1,HANOI_STAIR);

    closegraph();

}

  n개의 디스크를 가장 오른쪽으로 옮기는데 얼마나 시간이 걸릴까? 디스크
의 갯수가 n개라 하고, 이것을 옮기는데 소요되는 최소의 절차의 횟수를 Xn이
라고 하면, 1개일 때 X1은 1, 2개일 때 X2는 3이다. 3개일 때 X3은 7이 된다.
이 횟수는 다음과 같이 생각할 수 있다.

  (1) 가장 작은 것, 중간의 것 등, 2개의 원판을 중앙으로 옮기기 위해서는 3
번의 횟수가 필요하다.

  (2) 큰 원판을 가장 오른쪽으로 옮기기 위해서는 1번의 횟수가 필요하다.

  (3) 중앙에 옮겨져 있는 2개의 원판을 오른쪽으로 옮기기 위해서는 3번의
횟수가 필요하다.

  따라서,

X3=3+1+3=7

이 된다. X4에 대해서 구해보면,

X4=7+1+7=15

이 되는데, 이를 일반항으로 나타내보면,

Xn=Xn-1+1+Xn-1=2Xn-1+1

Xn=2n-1

이다. 원판의 갯수가 64인 경우,

264-1=18,446,744,073,709,551,615

인데, 원판 1개를 옮기는데 1초 걸린다고 했을 때, 1년 동안 쉬지 않고 1초마
다 1개씩의 원판을 옮기면,

365ㄪ24ㄪ60ㄪ60=31,536,000

번 옮길 수 있다. 그러므로 전부를 옮기려면 6000억 년이나 걸린다.

  계산 복잡도(computational complexity)

  우리가 접하는 문제를 계산하는데 필요한 시간의 비율 - 계산 복잡도 - 에
따라 나누어 보면 아래와 같이 나눌 수 있다.

■ 풀이 가능한 문제(solvable)

    □ 다룰 수 있는 문제(tractable)

        ① 실용적인 해법이 있는 문제(polynomial time solution)

        ② 매우 빠른 해법이 있는 문제(linear time solution)

    □ 다룰 수 없는 문제(intractable)

        ③ 실용적인 해법이 없을 거라 생각하는 문제(NP-hard■)

■ 풀이 불가능하다고 알려진 문제(unsolvable)

             NP-hard에서 NP는 Non Polynomial의 머리 문자(acronym)이다. 이것은 문
          제의 입력의 수가 선형적으로 증가함에 따라, 풀이에 걸리는 시간은 다항
          식이 어떤 함수로 표현되는 관계식에 따라 증가함을 의미한다. 다항식이
          아닌 함수는 지수 함수(polynomial function)가 대표적이지만, 지수 함수보다
          도 엄청나게 빠르게 증가하는 함수가 존재한다.

  하노이 탑 문제는 어디에 속할까? 위에서 살펴 본 것처럼, NP-hard에 속한
다. 입력의 크기가 선형적으로 증가함에도 불구하고 문제를 해결하는데는 지
수 함수적인 시간이 소요되므로, 타당하다고 간주되는 시간 안에 적당한 크기
의 문제를 풀이하는 것은 불가능하다.

  놀랍게도 쉽게 접할 수 있는 문제 중에 NP-hard에 속하는 문제는 많다. 바
둑에서 후보를 결정하는 것 역시 NP-hard에 속한다. 이창호분 같은 사람은
경험을 바탕으로(heuristic) 훌륭하게 다음 수를 결정한다. 컴퓨터의 관점에서
만 생각한다면, 사람은 절대로 흉내 낼 수 없는 훌륭한 병렬 컴퓨터(parallel
computer)이다.

공학도와 자연과학도에게

  물리학의 법칙이나 수학의 공식들을 모의실험(Simulation)하거나 식 자체를
구현할 때, 대부분이 재귀적으로 구현할 수 있다는 것을 잊어서는 안 된다. 여
러분이 알아야 할 것은 '재귀함수가 어떻게 동작하는가' 가 아니다. '여러분 자
신이 구현하고자 하는 응용에 재귀함수를 이용할 수 있는가' 이다.

  여러분들은 계속 이어지는 장(Chapter)들에서 재귀 호출을 사용하여, 자연
계의 물체를 모의 실험한다든지, 식물의 자라남을 모의 실험한다든지 하는 풍
부한 예제들을 보게 될 것이다.

  또한 어떤 문제들은 꼭 재귀 호출을 사용하지 않더라도 재귀적인 사고가 문
제를 해결하는 실마리가 될 수 있다는 것을 명심해야 한다.

  실습문제

1. 아래의 예는 조합(Combination)을 구하는 재귀 함수이다. 문제점을 설계하
고, 문제점을 해결한 새로운 버전(version)을 작성하라(힌트: Tail Recursion).

#include <stdio.h>

long Combination(long n,long r) {

    if (r==0 || r==n) return 1;

    else if (r==1) return n;

    else {

        return Combination(n-1,r-1)+Combination(n-1,r);

    }//if.. else..

}//Combination

void main() {

     printf("%ld\n",Combination(3,2));

}

2. 빠른 정렬(Quick Sorting)은 일반적으로 재귀적으로 정의한다. 빠른 정렬
알고리즘을 구현하라.

3. 프랙탈(Fractal)은 혼돈(Chaos)을 기술하는 언어로써 구현시에 일반적으로
재귀적으로 구현한다. 대표적인 프랙탈 곡선인 Koch 곡선을 구현하라.

4. 게임 트리 알고리즘을 재귀적으로 구현하라.

5. 트리(Tree)의 노드를 방문하는 방법은 Pre-Order, In-Order,
Post-Order가 있다. 링크드 리스트(Linked List)를 사용하여 이중(Double) 링
크드 리스트를 구현하여, 아래의 <그림 ?>와 같은 트리를 구성하라. 또한 이
트리를 각각의 방법으로 방문하여 결과를 출력하라.

<그림 ?> 트리


7. 앞의 결과를 이용하는 법

7. 앞의 결과를 이용하는
법

<그림 ?> 솟수(Prime Number)

  위의 그림을 책을 왼쪽으로 90°회전시켜서 보라. 어떠한 규칙성을 발견할
수 있는가? 만약 여러분이 발견한 규칙성을 수학적인 기호나 알고리즘으로
설명할 수 있다면, 여러분은 수 천년간 풀리지 않았던 솟수(Prime Number)
의 규칙성을 발견한 위대한 사람이 된 것이다!

1998/02/08 20:48 인터넷 동아일보

[해외토픽]美대학생，９０만자리 넘는 소수 발견

  미국의 한 대학생이 최근 소수(素數) 탐색 인터넷 프로그램을 이용, 지
금까지 발견된 것 중 가장 큰 90만9천5백26자리의 소수를 찾아내는데 성
공했다고.

  위의 그림은 다음과 같은 방법으로 그린 것이다. 예를 들면, 솟수 31을 부호
없는 32비트 정수(long)로 나타내면,

0000 0000 0000 0000 0000 0000 0001 1111

이다. 이를 0은 흰색으로 1은 검은색으로 솟수가 발견될 때마다 VGA 표준
그래픽 화면에 차례대로 그린 것이다. VGA의 해상도(Resolution)는 640×480
이므로 한 열(Column)에 480개의 솟수를 그릴 수 있다. 640/32=20이므로 위의
<그림 ?>는 480×20=9600개의 솟수를 차례대로 그린 것이다.

  여기서 여러분이 주목해야 할 것은 솟수가 아니라 '솟수를 구하는 방법'이
다. 이것은 뒤에서 이야기할 동적 프로그래밍(Dynamic Programming)의
기초가 되는 개념이므로 반드시 이해하여 자기 것으로 소화하기 바란다. 동적
프로그래밍은 엄청나게 많이 쓰인다. 특히 수학문제를 풀이하는 데에는.

  프로그램의 요지는 CPrime의 생성자 함수에서 호출하는 두개의 함수
Add(2)와 Add(3)에 있다. 흔히 사용되는 '에라토스테네스(Eratosthenes)의 체'
방법은 컴퓨터로 구현하기에 너무 복잡하다.

  몇 개의 솟수를 처음부터 차례대로 적어보면,

2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, ...

이다. 만약 여러분이 밑줄 친 2, 3은 이미 솟수로 구했다고 하자. 3보다 큰 솟
수는 어떻게 구할 것인가? 여러분이 주목해야 할 아이디어는 다음과 같다.

  솟수는 솟수로 나누어지지 않는다. 그리고 2, 3은 지금까지 구한 가장 작은
솟수이므로 앞으로 나타날 솟수는 2, 3으로 나누어 떨어지지 않아야 한다. 이
러한 것은 앞에서부터 차례대로 솟수를 구해간다면 무한히 많이 발견될 모든
솟수에 대해 성립한다.

  위에서 자연어(Natural Language)로 제시한 알고리즘은 뒤의 결과를 얻기
위해 앞의 결과를 사용하고 있다. 이것이 문제를 풀이하는 핵심이다. 문제의
풀이 과정을 그림으로 나타내면 <그림 ?>와 같다.

<그림 ?> 솟수를 구하는 과정

  일반적으로 원하는 해를 구하기 위해 지금까지의 계산 결과를 사용하는 것
을 동적 프로그래밍이라 한다. 동적 프로그래밍은 1차원, 2차원 혹은 3차원 이
상의 배열을 이용하여 구현하는데, 해에 수렴하는 과정은 <그림 ?>와 같다.
<그림 ?>의 (衁) 처럼 수렴하는 예로는 CYK 알고리즘을 들 수 있고, (遁)
처럼 수렴하는 예로는 스트링 매칭에 사용하는 Shift-And 방법이 있다.

<그림 ?> 동적 프로그래밍

#include <stdio.h>

#include <conio.h>

#include <graphics.h>

typedef unsigned long ULONG;

class CPrime {

    ULONG *p;

    ULONG cp;

  public:

    CPrime() {

        p=new ULONG[10000L];

        cp=0;

        Add(2); Add(3);

    }//CPrime

    ~CPrime() { delete[] p; }

    void Add(ULONG n) {

        p[cp++]=n;

    }//Add

    int IsPossible(ULONG);

};//class CPrime

int CPrime::IsPossible(ULONG n) {

    for (ULONG i=0;i<cp;++i) {

        if (0==(n%p[i])) return 0;

    }//for

    return 1;

}//CPrime::IsPossible

void DrawPrime(int x,int y,ULONG n) {

    ULONG m=0x80000000L;

    for (int i=0;i<32;++i) {

        if (m&n)

            putpixel(x,y,0);

        else

            putpixel(x,y,15);

        m>>=1;

        ++x;

    }//for

}//DrawPrime

void main() {

    int gd=0,gm;

    initgraph(&gd,&gm,"..\\..\\BGI");

    CPrime p;

    int x=0,y=0;

    ULONG i,count=0;

    for (i=5;i<4000000000L;i+=2) {

        if (kbhit()) {

            getch(); break;

        }//if

        if (p.IsPossible(i)) {

            p.Add(i);

            DrawPrime(x,y,i);

            ++count;

            ++y;

            if (0L==(count%480L)) {

                x+=32;

                y=0;

            }//if

        }//if

    }//for

    getch();

    closegraph();

}//main

실습문제

1. CYK 알고리즘을 구현하라.

2. Shift-And 방법을 구현하라.


8. 이진수

8. 만물은 음양의 이진법
으로 나타낼 수 있다.

  옛날 중국과 우리 나라에서는 모든 것을 음양으로 나누어서 따지는 경향이
강했으며, 지금도 그 전통이 뿌리 깊게 살아 있다. 우리 나라의 태극기가 이것
을 잘 상징하고 있다.

<그림 ?> 팔괘(八卦)

  옛날에는 두 가지 막대를 써서 앞으로 일어날 좋고 나쁜 일을 점쳤는데, 이
것을 역(易)이라고 부른다. 우리 나라 태극기의 네 구석에 있는 것은, 이 역의
원리의 일부이다. 팔괘라고 부르는 이 원리는 위의 8가지인데, 지금 "－"(양)
을 1, "--"(음)을 0으로 생각하고 고쳐 쓰면,

111, 011, 101, 001, 110, 010, 100, 000

과 같이 되어, 2진법의 0부터 7까지의 수와 꼭 들어맞는다. 이 사실을 처음으
로 지적한 사람은 독일의 철학자 라이프니쯔(Leibniz, 1646-1716)였다.

  음양 사상이란 태양과 달, 남자와 여자, 홀수와 짝수 등과 같이 세상의 모든
것을 음과 양으로 분류해서 생각하는 태도이다.

  이 음양 사상이 유럽으로 전해졌으며, 위대한 철학자와 과학자 중에는 그
영향을 받은 사람이 적지 않았다. 그 대표적인 예가 라이프니쯔에 의해 발명
된 2진법이다. 지금의 컴퓨터의 논리적 구조는 2진법인데, 이 2진법이 수학이
사실은 동양의 음양 사상의 영향을 받아 태어났다는 사실은 아주 흥미를 끈
다.

  지금 이 생각을 정수에 국한시켜 생각하면 모든 정수는 짝수와 홀수로 나누
어 생각할 수 있다. 짝수의 대표로 0, 홀수의 대표로 1을 뽑아 본다. 이 두 수
사이의 덧셈과 곱셈을 생각하면 다음 표와 같이 나타난다.

+

0

1

×

0

1

0

0

1

0

0

0

1

1

2

1

0

1

  위의 표를 보면 1+1=2인 경우만을 제외하고 계산 결과는 모두 0 아니면 1
이다.

  2는 짝수이기 때문에 이것을 짝수의 대표 0으로 바꾸어 놓아 보자. 즉,

1+1=0

이라고 해 보자. 그러면 이 덧셈표는 아래 표와 같이 된다.

+

0

1

0

0

1

1

1

0

  이것은 아래의 표에서 짝수, 홀수를 0, 1로 바꾸어 놓은 것이다.

+

짝수

홀수

×

짝수

홀수

짝수

짝수

홀수

짝수

짝수

짝수

홀수

홀수

짝수

홀수

짝수

홀수

  이렇게 함으로써 두 원소 0과 1로 된 집합

{0, 1}

에서 덧셈과 곱셈이 정해지고 계산의 결과도 이 집합의 테두리 안에서 얻어지
게 된다.

  정수 전체로 된 집합을 Z로 나타내 보자. 즉,

Z={..., -4, -3, -2, -1, 0, 1, ...}

이에 대해서 위의 두 원소로 된 집합을 Z2로 나타내 보자. 즉,

Z2={0, 1}

  Z2라는 집합은 Z의 원소를 짝수, 홀수라는 성질에 의하여 두 종류로 나누어
이 종류에 대해서만 생각하여 얻은 작은 집합이다.

  이 세상 모든 것에 수를 대응시키고 이것을 짝수, 홀수로 분류하여 0과 1만
으로 대표시킨 생각은 이 세상 모든 것을 음과 양으로 분류시킨 것과 근본적
으로 같은 생각이다. 그러나 동양에서는 끝까지 논리적으로 추구하지 않고 이
것을 점이나 미신으로 만들어 버렸다. 한편 최근의 컴퓨터는 True에는 1,
False에는 0을 대응시키고 위해서 만든 표로써 전자 회로를 설계하기도 한다.
모든 명제는 0과 1로 된다는 사상이다. 이처럼 같은 발상이 과학이 될 수도
있고 미신으로 그칠 수도 있다.

보충해 주는 수: 보수(Complement Number)

  r진수 m자리수 n의 보수는

rm-n

이다. 10진수에 대해서 생각해 보면, 7의 보수는 101-7=3이다. 77의 보수는
102-77=33이다. 10이나 100을 만들기 위해 보충해야 하는 수가 각각 3과 33이
라는 의미이다.

  보수는 뺄셈을 위해 사용할 수 있다. 예를 들어 10진수 8-3을 계산한다고
하면, 8+(3의 보수)=8+7=15 인데 1자리의 뺄셈이므로 십의 자리 1을 버리면,
8+7=5가 되어 원하는 결과가 얻어진다. 보수의 이러한 성질 때문에 컴퓨터는
정수(Integer)를 나타내기 위해 보수를 사용한다.

  2진수에서 1의 보수(1's Complement)와 2의 보수(2's Complement)를
구하는 것은 쉽다. 1의 보수는 단순히 1과 0을 토글(Toggle)시킴으로 구할 수
있고, 2의 보수는 1의 보수를 구한 다음, 결과에 1을 더하면 구할 수 있다. 예
를 들어, 1001011100의 1의 보수는 0110100011이며, 2의 보수는 0110100011 +
1 = 0110100100이다.

  2의 보수는 더 간단하게 구하는 방법이 있는데, 오른쪽에서 왼쪽으로 처음
으로 1을 만날 때까지 스캔(Scan)하며 만난 숫자를 적다가, 처음 1 이후의 모
든 비트를 토글하면 된다.

<그림 ?> 2의 보수를 구하는 법

  컴퓨터가 정수를 나타내는 방식은 일반적인 2진수 체계를 따르지만, 음수를
나타내기 위해서 2의 보수를 사용한다. 2진수 n비트로 나타낼 수 있는 수의
범위는

0∼2n-1

이다. 그러므로 unsigned char형은 0∼28-1, 즉 0∼255를 나타낼 수 있고,
unsigned short int형은 2바이트이므로, 0∼216-1, 즉 0∼65535를 나타낼 수 있
다. 음수인 경우는 어떻게 할까? 음수인 경우는 가장 중요한 비트(Most
Significant Bit: MSB)를 부호 비트(Sign Bit)로 사용하여, MSB가 1인 경우
는 이 수의 2의 보수를 취한 값을 음수로 한다. 즉 부호는 음수로 결정된 상
태에서 이 수의 2의 보수를 구하여 값을 취하므로, MSB자체도 값의 결정에
사용될 수 있다. 부호 있는 3비트로 1의 보수와 2의 보수로 각각의 정수를 표
현해 보면 <표 ?>와 같다.

1의 보수

정수값

2의 보수

정수값

000

001

010

011

100

101

110

111

+0

+1

+2

+3

-3

-2

-1

-0

000

001

010

011

100

101

110

111

+0

+1

+2

+3

-1

-3

-2

-1

<표 ?> 1의 보수와 2의 보수의 부호 있는 정수값

  예를 들어, 101의 경우 1의 보수에 대해서,

MSB가 1이므로 음수

101의 1의 보수는 010=2

이므로 -2가 된다. 하지만 2의 보수에 대해서는,

MSB가 1이므로 음수

101의 2의 보수는 011=3

이므로 -3이 된다. 이 세상에 존재하는 모든 디지털 컴퓨터가 2의 보수를 이
용하여 정수를 표현하는 이유는 올림수를 무시해도 되는 이점 때문이다. 예를
들어 3-2를 하는 경우 1의 보수인 경우,

011+101=1000

밑줄 친 것처럼 올림수가 발생한 경우 이를 다시 더해야 한다. 그러므로,

000+1=001

이 되어 원하는 결과 1이 얻어진다. 하지만 2의 보수인 경우는 단순히 올림수
를 무시하면 되므로 +연산을 적게 사용하므로 훨씬 효율적이다.

<그림 ?> 2의 보수에서 올림수의 무시

  2의 보수로 수를 표현했을 때, n비트의 부호있는 정수의 범위는 다음과 같
다.

-2n-1∼2n-1-1

그러므로, signed short int인 경우 -32768(-215)∼32767(215-1) 범위의 수를 표
현할 수 있다. 그렇다면, 아래 프로그램의 결과는 얼마가 인쇄될까?

#include <stdio.h>

void main() {

    char c=129;

    printf("%d\n", c);

}

  129는 2의 보수로 0000 0000 1000 0001 이다. 이 숫자를 8비트 char변수 c
에 대입하는 과정에서 상위 8비트는 잘려진다. 그러므로 c는 1000 0001이다.
이것을 printf가 출력하는데 부호 있는 정수형이므로 2의 보수 체계를 따라

MSB가 1이므로 음수

1000 0001의 2의 보수는 0111 1111=127

이므로 -127이 인쇄된다.

비트 플래그(Bit Flag), 비트 마스크(Bit Mask)

  위에서 설명한 2진수의 개념이 도대체 나와 무슨 상관인가? 하지만 여러분
은 곧 위의 2진수 개념이 여러분과 무진장 많이 상관되어 있다는 것을 알게
될 것이다.

  아래의 프로그램은 구구단을 출력하는 프로그램인데 구구단을 출력하다가
사용자가 임의의 키를 누르면 종료하도록 만들려고 한다. 아래에 설명 부분을
보지 말고 여러분이 직접 고쳐보기 바란다.

#include <stdio.h>

#include <conio.h>

void main() {

    int i=1,j;

    clrscr();

    while (i<100) {

        ++i;

        j=1;

        while (j<100) {

            printf("%5d*%5d=%8d",i,j,i*j);

            ++j;

        }//while

    }//while

}

  문제는 while문안에서 break를 사용해야 한다는 것인데, 단순하게 안의
while문안에서

if (kbhit()) break;

해서는 문제 해결이 되지 않는다. break는 자기를 둘러싼 가장 가까운 반복
구조의 블락을 탈출하기 때문이다. 즉 break는 안의 while문을 탈출하기만
할뿐이다. 그러므로 안의 while문에서 어떤 사건이 발생했다는 것을 기억시키
기 위해서 특정한 변수를 사용하여야 한다. 변수가 이러한 목적으로 사용되었
을 때 깃발(Flag) 변수라고 한다. 깃발 변수를 사용한 소스를 아래에 리스
트하였다.

#include <stdio.h>

#include <conio.h>

void main() {

    int i=1,j;

    int flag;//사건을 기록하기 위해서 사용된다

    clrscr();

    while (i<100) {

        ++i;

        j=1;

        while (j<100) {

            printf("%5d*%5d=%8d",i,j,i*j);

            ++j;

            if (kbhit()) {

                flag=1;

                break;

            }//if

        }//while

        if (flag==1) break;//사건이 일어난 경우 while을 탈출한다

    }//while

}

  어떤 정수형 변수의 이진 비트열의 각각의 비트들이 이러한 깃발 변수로 사
용되었을 때를 비트 플래그라고 한다.

  예를 들어 정수형 변수는 16비트이므로 16가지의 On/Off 상태를 기록할 수
있다. 만약 상태의 개수가 4가지라면, 상태를 나타내는데 2비트가 필요하므로
8가지의 깃발 변수로 사용할 수 있다.

  예를 들어 컴퓨터 바둑 프로그램을 구현할 때 판(Board)의 각각의 위치에
대해,

  (1) 판의 현재 상태: 2비트(비었음, 검은돌, 흰돌, 가장자리)

  (2) 이웃한 검은색 돌의 수: 2비트(Neumann이웃이므로 최대 4까지 가능)

  (3) 이웃한 흰색 돌의 수: 2비트

  (4) 덩어리 번호: 8비트

  (5) 마킹을 위한 임시 비트: 2비트

를 유지한다고 하자. 그러면 여러분은 정수형 배열 board[19+2][19+2]를
선언해서 각각의 판 위치에 대해 <그림 ?>처럼 정보를 유지할 것이다.

<그림 ?> 판이 유지하는 정보

  얼마만큼의 공간이 효율적인가? 위처럼 사용하면 board가 차지하는 메모리
는 21×21=441 바이트이다. 만약 여러분이 비트 플래그를 몰라서 구조체 배열
을 사용한다면,

    struct BOARD {

        char nState, nBlack, nWhite;

        unsigned char nGroup;

        char nMarking;

    } board[21][21];

처럼 선언해서 각각의 자리마다, 5바이트를 사용하므로, 21×21×5=2205 바
이트를 사용해야 한다. 또한 공간만이 문제가 아니라 속도나 함수의 파라미터
수 등이 문제가 될 수 있다.

  다음으로 우리가 고려해야 할 문제는 '어떻게 각각의 비트 플래그를 갱신
하는가?' 이다.

<그림 ?> board[y][x]의 상태

  예를 들어 <그림 ?>와 같은 상태의 board[y][x]의 값은,

00 00000100 10 01 01

이다. 이는 (x,y)에 검은 돌이 있으며, 4-연결 이웃에 각각 검은돌, 흰돌이 1
개, 2개 있음을 나타내면, 덩어리 번호는 4임을 나타낸다. 이 상황에서 4의 마
지막 남은 이웃 자리에 검은 돌을 놓았다고 하자. 그러면,

00 00000100 10 10 01

이 되어야 한다. 어떻게 밑줄 친 비트의 일부만을 갱신할 것인가? 절차는 다
음과 같다.

  (1) 갱신하고자 하는 비트열만을 일반 정수 형태로 변환한다.

  (2) 비트열을 갱신한다.

  (3) 갱신된 비트열을 원래 자리에 다시 가져다 놓는다.

이를 갱신하는 루틴은 아래와 같이 쓸 수 있다.

  unsigned int i=board[y][x];//i= 00 00000100 10 10 01

  unsigned int t;

  t=(i & 0x000c)>>2;//0x000c는 이진수로 0000 0000 0000 1100이다

  ++t;//t가 1에서 2가 된다

  i=(i & 0xfff3) | (t<<2);//0xfff3은 이진수로 1111 1111 1111 0011이다

  board[y][x]=i;

  위의 코드를 처음 보는 사람은 이해가 되지 않을 것이다. 하지만 위의 코드
는 비트 마스크의 조합에 불과하다. 비트 마스크는 비트 연산자 &, | 를 사용
하여 특정한 비트를 1로 혹은 0으로 만드는 것을 말한다. & 와 | 연산은 아래
와 같은 특징을 가진다.

<그림 ?> 비트 마스크

  즉, 1과 & 하면 원래의 비트가 유지되며, 0과 & 하면 모두가 지워진다. 또
한 1과 | 하면 모두가 1이 되며, 0과 | 하면 원래의 비트가 유지된다. 이러한
원리를 이해하면 위의 소스 코드가 이해되리라 믿는다.

실습문제

1. 아래의 출력 결과는 얼마인가?

    #include <stdio.h>

    void main() {

        int i=7;

        printf("%d\n",~i+1);

    }

2. 아래의 출력 결과는 얼마인가?

    #include <stdio.h>

    void main() {

        int i=0x5555, j=0x1234, k;

        k=i^j;

        printf("%x\n",k);

        printf("%x\n",k^j);

    }

3. 아래의 프로그램에서 Product(a,b) 함수는 a*b를 쉬프트(shift) 연산과 모듈
로(modulo) 연산만을 사용하여 계산한다. Product() 함수를 자세히 설명하라
(힌트: Booth 알고리즘).

#include <stdio.h>

long Product(long a,long b) {

    long p=0;

    while (a>0) {

        if (a%2==1) p+=b;

        a>>=1;

        b<<=1;

    }//while

    return p;

}//Product()

void main() {

    printf("%ld\n",Product(3,7));//결과는 3*7=21이다.

}

4.


9. 유전자 알고리즘(Genetic Algorithm)

9. 유전자 알고리즘
(Genetic Algorithm)

  필자가 어느 멋있는 후배 - 개인적으로 너무 좋아하는 - 를 통해 처음 알
게 된 유전자 알고리즘의 감동을 잊을 수 없다. 독자 중 어떤 사람은 단지 이
부분만을 보고 이 책을 구입했을 지도 모른다. 그렇다면 독자 여러분은 최선
의 선택을 한 것이다. 이 장은 유전자 알고리즘에 대해 실용적이며 또한 이론
적인 부분을 자세히 설명하고 있다.

  9.1 들어감

  유전자 알고리즘은 진화(Evolution)라는 주제에 고무되어 나타난 계산 모델
(Computational Model)의 한 종류이다. 이 알고리즘은 염색체 비숫한 자료구
조(Chromosome-like Data Structure)를 사용하여 잠재적인 해를 특정한
문제의 해로 부호화(Encode)하며, 이러한 자료 구조에 재조합 연산자
(Recombination Operator)를 적용하여 결정적인 정보를 보존한다. 유전자
알고리즘은 흔히 함수 최적화 문제로 보여지지만, 이 알고리즘이 적용되는 범
위는 무척 넓다.

  유전자 알고리즘을 구현할 때 염색체 집단(Population of Chromosome) -
대개 임의로 선택된 - 에서 시작한다. 이러한 자료 구조를 평가하여 재생산성
의 기회를 평가한다. 좀 더 해(Solution)에 근접한 값을 가지는 염색체가 많은
기회를 얻게 되어 다음 세대에 살아 남을 확률이 높아진다. 좋음의 정도는 현
재 모집단에 대해 상대적으로 정의된다.

  이러한 설명은 유전자 알고리즘이 두 가지 의미로 사용되기 때문에 다소 추
상적이다. 엄밀하게 번역하면 유전자 알고리즘은 홀런드(John Holland, 1975)
와 그의 학생들(예를 들면 DeJong, 1975)에 의해 소개되고 연구된 모델을 의
미한다. 아직도 많은 유전 알고리즘들이 이 모델에 근거하고 있으며, 다른 변
형들과 구분하기 위해 이 알고리즘을 규범적 유전자 알고리즘(Canonical
Genetic Algorithm)이라 한다. 최근의 진보적인 이론들도 이 규범적 유전자
알고리즘을 사용하고 있다(Vose, 1993).

  넓은 의미에서 사용하면 유전자 알고리즘은 탐색 공간(Search Space)에서
새로운 해를 샘플링하기 위해 고르고(Select) 재조합(Recombination)하는 모든
모집단에 근거한 모델을 의미한다. 연구자들에 의해 도입된 많은 유전 알고리
즘 모델은 실험에 근거하고 있다. 이러한 연구가들은 적용 지향적(Application
Oriented)이며, 최적화의 도구로써 유전자 알고리즘을 사용하고 있다.

부호화(Encoding)와 최적화 문제(Optimization Problem)

  대게 유전 알고리즘에서 문제에 의존적인 큰 부분은 두 가지 인데, 문제 부
호화와 평가 함수가 그것이다.

  변수들의 집합에서 이익을 최대로 하거나, 비용을 최소로 하는 파라미터 최
적화 문제(Parameter Optimization Problem)를 고려해 보자. 우리는 그러한
문제를 다른 파라미터를 표시하기 위해 일련의 버튼을 가진 검은 상자(Balck
Box)로 간주 할 수 있다. 이 검은 상자의 유일한 출력은, 파라미터 들의 적
절한 조합이 얼마나 최적화 문제를 풀이하는지를 나타내는 평가함수의 값 뿐
이다. 목표는 파라미터들을 잘 조정하여 최적의 출력을 찾는 것이다. 좀 더 전
통적인 용어를 사용한다면, 우리는 함수 F(X1,X2,...,XM)을 최적화하는 것이다.

  유전자 알고리즘은 대부분 비선형적인(Nonlinear) 문제와 관련이 되어 있다.
비선형의 의미는 문제를 풀이하기 위해 여러 개의 파라미터 중 한 개를 다른
변수들과 독립적으로 간주하는 것이 불가능 하다는 의미이다. 이 검은 상자
의 출력을 최적화하기 위해서는 파라미터의 조합이 일으키는 상호작용을 고려
하여야 한다. 유전 알고리즘에서는 이러한 상호작용을 편지(Epistasis)라고도
한다.

  첫 번째 가정은 파라미터 변수들을 비트 스트링으로 나타낼 수 있어야 한다
는 것이다. 이것은 변수들이 적당한 형태로 이산화(Discretized)되어야 한다는
것과 범위가 2의 멱승(Power of 2)에 대응되어야 함을 의미한다. 예를 들면,
각 파라미터마다 10비트를 사용하면 범위는 1024까지이다. 파라미터의 값들이
실제로는 연속이더라도 이러한 이산화가 문제가 되지는 않는다. 물론 이산화
가 문제가 되지 않기 위해서는, 원하는 정확도를 얻을 정도로 이산화가 충분
한 해상도를 제공한다는 가정하에서다. 이산화는 또한 어떤 의미에서 뒤에서
동작하고 있는 함수(Underlying Function)를 대표한다고 가정한다.

  입력 파라미터들이 정확한 유한 집합일 경우는 부호화 문제는 더욱 어렵
다. 예를 들면, 어떤 변수 Xi에 대입될 수 있는 값이 1200개의 이산적인 값
이라면 어떻게 할 것인가? 이러한 범위를 만족시키기 위해서는 11비트가 필
요하다. 하지만 11비트는 2048개의 값을 표현할 수 있다. 848개의 남은 비트
패턴은 평가하지 않거나, 평가가 되어도 최악의 값으로 평가되어야 한다. 그러
한 부호화 문제는 평가함수를 설계하는 과정에서 고려되어야 한다.

  부호화 문제와는 별도로 평가 함수는 문제 기술(Problem Description)의 일
부로써 간주된다. 즉 평가 함수를 설계하는 것이 모의 실험의 방법 그 자체를
포함할 때가 많다. 하지만 다른 경우는 평가 함수가 수행 능력에만 근거하거
나 근사적이거나 부분적인 평가만을 하는 경우도 있다. 예를 들면, 시스템이
지수적인 큰 상태를 가질 수 있는 제어 기계를 생각해 보자. 유전자 알고리즘
이 어떤 형태의 제어 전략을 평가한다면, 탐색 공간은 제한된 형태로 샘플링
되어야 하며, 평가의 결과 또한 근사적이며 잡음이 있다.

  평가 함수는 상대적으로 빨라야 한다. 전통적인 모든 유전 알고리즘이 그러
하지만, 어떤 문제에 대해서는 논쟁거리가 될 수 있다. 유전자 알고리즘이 잠
재적인 해들의 집단을 가지고 동작하기 때문에, 이러한 모집단을 평가하는 비
용을 포함한다. 게다가 모집단은 세대가 지남에 따라 교체된다. 모집단의 멤버
들은 재생산하면, 이러한 재생산된 자손들은 재평가된다. 평가를 하는데 1시간
이 소요된다면, 10,000번의 평가를 위해서는 1년이 걸린다. 이것은 200개의 스
트링으로 이루어진 모집단의 50세대만을 표현할 뿐이다.

어려다면 어떻게 어려운가?

  파라미터들 사이의 상호작용이 비선형적이라고 가정하자. 즉, 탐색 공간의
크기는 문제를 부호화하는데 사용된 비트의 크기와 관련된어 있다. 길이가 L
인 비트 스트링 부호화에 대해, 탐색 공간의 크기는 2L이고 초육면체
(Hypercube)를 형성한다. 유전자 알고리즘은 이러한 L 차원의 초육면체의
모퉁이를 샘플링한다.

  일반적으로, 대부분의 평가 함수는 적어도 30비트 정도이며 대부분의 연구
가들은 그 정도의 큰 함수가 필요하다는 데에 동의한다. 물론 2L은 L에 대해
지수적으로 증가한다. 400비트의 부호화 문제를 고려해 보자. 관련된 탐색 공
간은 얼마나 큰가? 인공 지능의 고전적인 책들은 이러한 크기의 공간에 대
해 한가지 특징을 말해준다. Winston은 2400이 가능한 체스 판의 상태를 탐색
하는데 효과적인 크기라고 지적한다. "이것은 터무니없이 큰 수"라고
Winston은 말하고 있다.

  요점은 탐색 공간의 크기에 비해 좋은 해의 수는 너무 희박하다는 것이다.
그러면 임의 찾기(Random Search) 혹은 차례대로 찾기(Enumeration Search)
는 문제를 풀이하는 실용적인 해가 될 수 없다. 또한, 임의 찾기 이외의 어떠
한 탐색 방법도 좋은 해를 어떻게 보고, 어디서 보느냐에 따라 편중(Bias)되어
서 나타난다. 유전자 알고리즘은 탐색 공간에서 무슨 새로운 점들이 샘플링
되었느냐에 따라서 특별한 편중(Particular Bias)을 도입하고 있다. 그럼에도
불구하고, 유전자 알고리즘은 인공 지능의 측면에서 "약한 방법(Weak
Method)"으로 알려져 있다. 왜냐하면 풀이하려는 문제에 대해서 약간의 가정
만을 하기 때문이다.

  물론 수학과 OR(Operation Research)에 힘입은 많은 최적화 방법이 개발되
었다. 최적화의 도구로써 유전자 알고리즘은 어떤 역할을 하는가? 종종 유전
자 알고리즘은 기울기 정보를 사용하지 않는 전반적인 탐색 방법(Global
Search Method)으로 기술된다. 이와 같이 유전자 알고리즘은 미분 불가능한
(Nondifferentiable) 많은 국지 최적해(Local Optima)를 갖는 문제에 적용할
수 있다. 약한 방법으로서의 유전자 알고리즘은 강하고 매우 일반적이다. 특정
한 문제에 특별한 좋은 최적화 방법이 존재한다면, 유전자 알고리즘은 그 최
적화 문제의 최적의 도구는 될 수 없다. 최근들어 이미 존재하는 알고리즘과
유전자 알고리즘을 결합하여 복합 알고리즘(Hybrid Algorithm)이 연구되고
있다.

  9.2 규범적인 유전자 알고리즘

  어떠한 유전자 알고리즘이든 간에 첫 번째 단계는 초기 모집단(Initial
Population)을 생성하는 것이다. 규범적인 유전자 알고리즘에서 이 모집단의
각각의 멤버는 길이가 L인 이진 스트링이다. 이 이진 스트링은 문제를 부호화
한 것에 대응한다. 각각의 스트링은 가끔식 유전형(Genotype) 혹은 염색체
(Chromosome)라고 하기도 한다. 대부분의 경우에 초기 모집단은 임의로 생
성된다. 초기 모집단이 생성된 후에 각각의 스트링은 평가되어 적합도 값
(Fitness Value)을 가지게 된다.

  평가(Evaluation)와 적합도의 개념은 가끔식 서로 혼용되어 사용된다. 그러
나, 유전자 알고리즘에 사용된 평가 함수(Evaluation Function or
Objective Function)와 적합도 함수(Fitness Function)는 구분하는 것이
유용하다. 평가함수는 특정한 파라미터 집합에 대한 수행능력(Performance)을
측정하고, 적합도 함수는 수행능력 값을 재생산의 기회를 나타내는 값으로 변
경한다. 파라미터 집합을 표현하는 한 스트링에 대한 평가는 다른 스트링의
평가와는 독립적이다. 그러나 그 스트링의 적합도는 현재 집단의 다른 스트링
에 관계해 결정된다.

  규범적인 유전자 알고리즘에서 적합도는 fi/로 정의된다. fi는 스트링 i의
평가값을 나타내고, 는 현재 모집단의 모든 스트링의 평가값의 평균을 나타
낸다. 적합도는 모집단에서 스트링의 서열에 의해서 값이 결정될 수 있다. 혹
은 토너먼트 선택(Tournament Selection)같은 샘플링 방법을 사용할 수도 있
다.

  유전자 알고리즘의 수행을 2단계의 작업으로 보는 것은 많은 도움이 된다.
현재 모집단(Current Population)으로 시작한다. 현재 집단에 선택
(Selection)이 적용되어 임시 모집단(Intermediate Population)이 만들어 진

그림 1: 한 세대는 선택 단계(selection phase)와 재조합 단계(recombination phase)로 나누어진다.
이 그림은 선택 단계동안 인접한 슬럿끼리 할당된 스트링을 나타낸다. 사실상, 중간 모집단을 충
분히 섞기 위해 스트링은 슬럿에 임의로 할당될 수 있다. 돌연 변이(그림에 나타나지는 않았다)
는 교배 후에 적용할 수 있다.

다. 다음으로 다음 세대 모집단(Next Population)을 만들기 위해 재조합
(Recombination) 혹은 돌연변이(Mutation) 연산이 적용된다. 현재 모집단에
서 다음 모집단으로 진행하는 것이 유전자 알고리즘에서 한 세대(One
Generation)를 구성한다. 황금빵(Goldberg)은 이러한 기본적인 구현을 간단한
유전자 알고리즘(Simple Genetic Algorithm: SGA)이라 하였다.

  우리는 먼저 현재 모집단에서 중간의 모집단을 만드는 과정을 살펴볼 것이
다. 첫번째 세대에서는 현재 모집단이 초기 모집단이 된다. 현재 모집단의 모
든 스트링에 대해 fi/f를 계산한 다음 선택(Selection)을 수행한다. 규범적인 유
전자 알고리즘에서 현재 모집단의 스트링이 다음세대에 복사될 확률은 적합도
에 비례한다.

  선택을 하는 방법은 다양하다. 현재 모집단을 룰렛 휠(Roulette Wheel)로의
사상(Mapping)으로 볼 수 있다. 여기서 각각의 개체(Individual)는 적합도에
비례하는 공간을 차지한다. 룰렛 휠을 계속해서 돌림으로써, 중간 모집단을 채
우기 위해서 "교체를 위한 확률적 샘플링"을 사용하여 개체를 선택한다.

  예상되는 적합도 값에 더욱 가깝게 선택하는 과정은 "나머지의 확률적 샘플
링(Remainder Stochastic Sampling)"이다. fi/f가 1.0보다 큰 각각의 스트링 i
에 대해, 이 숫자의 정수 부분은 다음 세대에 얼마만큼이나 복사될 것인가를
나타낸다. 또한 모든 스트링은 fi/f의 실수 부분에 비례하는 확률로 다음 세대
에 더 복사된다. 예를 들어 fi/f=1.36이면 다음 세대에 1개가 복사되며, 나머지
1개가 복사될 0.36의 기회를 가진다. fi/f=0.54면 다음 세대에 1개의 스트링이
복사될 기회로 0.54를 가진다.

  "나머지의 확률적 샘플링(Remainder Stochastic Sampling)"은 확률적
전 샘플링(Stochastic Universal Sampling)이라는 방법으로 효과적으로 구
현할 수 있다. 모집단이 파이(Pie) 그래프처럼 임의의 순서로 나열되어 있다고
하자. 각각의 개체는 적합도 비율에 따른 공간을 차지하고 있다. 다음으로 바
깥의 룰렛 휠은 파이를 둘러싸면서 N개의 균일하게 배치된 포인터를 가지고
있다. 룰렛 휠을 한 번 돌림으로써 중간 모집단의 모든 N개의 멤버를 동시에
정할 수 있다. 이렇게 하여 선택하면 치중되지 않는다.

  선택이 행해진 다음 중간 모집단은 구성되었으므로 재조합
(Recombination)을 실행한다. 이것은 중간 모집단으로부터 다음 모집단을 생
성하는 것이다. 임의로 짝 지워진 두 개의 스트링에 대해 확률 pc로 교배
(Crossover)가 진행된다. 이 상황에서 모집단의 개체들은 선택과정에서 충분
히 섞여져 있다. 두 개의 스트링을 잡는다. 확률 pc로, 다음 모집단의 새로운
스트링을 형성하기 위해 재조합한다.

  다음과 같은 이진 스트링을 고려해 보자.

1101001100101101

이 스트링은 파라미터 최적화 문제의 가능한 어떤 해를 나타낸다. 두 개의 부
모 스트링을 재조합하여 새로운 표본의 생성된다. 위의 이진 스트링과 x, y를
각각 0, 1에 대응시켜 아래의 스트링을 고려해보자.

yxyyxyxxyyyxyxxy

  임의로 선택된 재조합 위치에 의해 1-점 교배(1-point crossover)는 다음
과 같이 일어난다.

11010 \ / 01100101101

yxyyx / \ yxxyyyxyxxy

  두 개의 부모 스트링의 조각을 교체(Swapping)함으로써 아래의 자손
(Offspring)들을 생성한다.

11010yxxyyyxyxxy

yxyyx01100101101

  재조합후에 돌연변이(Mutation) 연산을 적용한다. 모집단에 속한 개체의
각각의 스트링에 대해 다소 낮은 확률 pm으로 돌연변이가 일어난다. 일반적으
로 돌연변이의 확률은 1%미만이다. 어떤 경우에 돌연변이는 임의로 새로운
비트를 생성하는 것이다.

  선택의 과정후에 재조합과 돌연변이가 끝이났고 다음세대의 모집단이 평가
된다. 평가, 선택, 재조합과 돌연변이의 과정은 유전자 알고리즘의 수행에 있
어서 한 세대를 형성한다.

왜 이것이 동작하는가? 초 육면체로서의 탐색 공간

  유전자 알고리즘을 처음 접하는 사람이 이 시점에서 궁금해 하는 첫 번째
질문은 바로 왜 그러한 과정이 유용하게 해를 찾는가 하는 것이다. 왜 이러한
동작이 탐색 문제나 최적화 문제에 효율적인 해를 낳게 되는가?

  이러한 물음에 대한 대답은, 계산이 진행되는 과정을 John Holland의 연구
결과물로부터 설명하는 것이 일반적이다. 그가 1975년에 저술한 고전적인 책,
"Adaptation in Natural and Artificial System"에 의하면, 어떻게 유전자 알고
리즘이 탐색공간의 초평면(Hyperplane)의 일부를 잠재적으로 샘플링하여 복잡
하고 강한 탐색(Complex and Robust Search)을 하는지에 대해 설명하고
있다.

  아마도 유전자 알고리즘이 어떻게 초평면의 일부를 샘플링하는지 이해하는
가장 최선의 길은 <그림 ?>처럼 간단한 3차원 공간을 예로 드는 것이다. 문
제가 3비트로 부호화되었다고 가정하자. 이것은 원점을 000으로 하는 간단한
육면체로 나타낼 수 있다. 육면체의 모서리들은 비트 스트링으로 딱지되어 있
고, 모든 인접한 모서리들은 반드시 서로 1 비트만 다르다. 이러한 예를 <그
림 ?>에 나타내었다. 앞쪽의 평면은 0으로 시작하는 모든 점들로 구성되어 있
다. "*"를 상관없음(Don't Care) 혹은 넓은 문자(Wild Card)로 사용한다면,
이 평면을 0**로 나타낼 수 있다.  *르 포함하는 스트링을 스키마타
(Schemata)라고 한다. 각각의 스키마타는 탐색공간에서 하나의 초평면에 대
응한다. 초평면의 차수(Order)는 스키마타에 포함된 실제 비트의 수를 나타
낸다. 그러므로 1**의 차수는 1이며, 1**1******0**의 차수는 3(Order-3)이다.

  <그림 ?>는 3차원의 육면체를 포함하는 3차원 육면체를 가진 4차원 초육면
체를 나타낸다.  점들은 이웃한 모서리들은 정확하게 1비트씩만 틀려야 하므
로 다음과 같이 마킹할 수 있다. 먼저 안의 육면체와 바깥의 육면체를 3차원
육면체를 마킹하듯이 마킹하고 바깥의 육면체에는 4번째 비트에 0을 추가하
고, 안의 육면체에는 1을 추가한다. 안의 육면체는 1***의 초평면을 나타낸다.
반면에 바깥의 육면체는 0***의 초평면을 나타낸다. *0**는 두 육면체의 앞
쪽 면을 나타내고, 10**는 안 쪽 육면체의 앞 쪽 평면을 나타낸다.

  스키마타의 *를 적당하게 바꾸어 특정 비트 스트링을 구성할 수 있으면, 그

그림 2(a):

그림 2(b):

비트 스트링은 스키마타와 매칭(Matching)된다. 일반적으로 특정한 스키마타
와 매칭되는 모든 비트 스트링은 그 특정한 스키마타를 표현하는 초평면에 포
함되어 있다. 이진 부호화된 스트링의 길이를 L이라고 했을 때, 각각의 이진
스트링은 초평면의 구석에 대응하는 염색체이며, 2L-1개의 서로 다른 초평면
중에 속한다. 모든 심벌이 *인 스트링은 탐색공간 그 자체를 나타낸다. 이것
은 임의의 비트 스트링을 가져와 * 자리에 모든 0, 1의 조합을 대응시켜보면
쉽게 알 수 있다. 다른 말로 하면, 비트 스트링에는 L개의 자리가 있고 각각
의 자리는 그 스트링에 포함된 비트 값 자체이거나 * 심벌이다.

  전체 탐색 공간에 걸쳐 3L-1개의 초평면이 정의 되어 있다는 것도 쉽게 알
수 있다. 비트 스트링의 각각의 자리에 대해 *, 1 혹은 0이 가능하므로, 3L의
조합이 가능하다.

  탐색 공간의 각 점들이 독립적으로 검사된다면, 각각의 스트링이 2L-1 초평
면의 일부라는 것을 확립하는 것은 많은 정보를 제공하지는 않는다. 이것은
모집단에 근거한 탐색의 개념이 왜 유전자 알고리즘의 중요한 부분인가를 보
여준다. 표본 점들의 모집단은 많은 수의 초평면에 관한 정보를 제공한다. 유
전자 알고리즘의 중요한 부분인 본질적인 혹은 잠재적인 병렬성(Intrinsic
or Implicit Parallelism)은 스트링의 집단이 평가 될 때 많은 초평면이 샘플
된다는 사실에서 유도된다. 사실상 모집단에 있는 스트링의 수보다 훨씬 많은
수의 초평면이 샘플링된다는 것은 논쟁이 될 수 있다. 한 개의 스트링이 평가
될 때 많은 초평면이 잠재적으로 병렬적인 형태(Implicitly Parallel
Fashion)로 평가된다. 그러나 점들의 모집단을 평가하는 누적의 효과에 의해
서, 초평면의 특정 부분집합에 대한 통계적인 정보가 제공된다.

  잠재적인 병렬성은 많은 초평면들이 병렬적으로 동시에 처리된다는 의미이
다. 이 이론은 재생산과 재조합의 과정을 통해, 서로 경쟁하는 초평면의 스키
마타가 상대적인 적합도에 따라 모집단 내에서의 자신의 표현을 증가시키거나
감소시킨다는 의미이다. 유전자 알고리즘이 스트링의 집단에 대해 동작하기
때문에, 모집단의 특정 초평면을 나타내는 한 개 스키마타의 비율적인 표현을
따라 갈 수 있다. 그리고 시간이 지남에 따라, 그 초평면의 모집단에서의 표현
이 증가하는지 감소하는지를 지적할 수 있다.

  9.3 초평면 샘플링의 두 가지 관점

그림 3:

  초평면 부분을 보는 다른 관점이 <그림 ?>에 나타나 있다. 한 변수에 대한
함수가 함수 최대화를 목표로 일차원 공간상에 그려져 있다. 초평면
0****...**는 공간의 첫 번째 반을 덮는다. 1****...**는 나머지 반을 덮는다.
0****...**에 속한 스트링이 평균적으로 1****...**에 속한 스트링보다 좋기 때
문에 탐색이 이 쪽으로 비례적으로 치중될 것이다. 두 번째 그래프에서,
**1**...**에 대응하는 공간의 부분들은 검게 표시하였다. 이것은 0****...**와
**1**...**의 교차부분, 즉 0*1*...**를 부각시킨다. 마지막으로 세 번째 그래프
에서 0*10**...**가 부각되어 있다.

  <그림 ?>에서 중요한 점은 초평면 부분들의 샘플링이 국부 최적해(Local
Optima)에 영향을 받지 않는다는 것이다. 동시에 다른 경쟁적인 부분들과 비
교해서 평균적으로 높은 부분들의 샘플링 비율을 높이는 것이 전역 최적해
(Global Optima)에 수렴하는 것을 보장하지는 않는다. 예를 들면, 전역 최적
해는 상대적으로 독립된 정점일 수 있다. 그럼에도 불구하고, 전역적인 경쟁력
을 가진 좋은 해는 발견된다.

  간단한 유전자 알고리즘이 실제로 동작하는 것을 관찰하는 것은 좋은 연습
이 된다. <표 ?>에서 스트링의 처음 3비트는 명시되어 있고, 나머지 비트들은
생략되어 있다. 목표는, 스트링이 적합도에 의해 복사될 때, 선택과정에서 실
제로 어떠한 일이 일어나는지 알기 위해서, 처음 3비트에 의해서 정의된 그러
한 초평면들을 관찰하는 것이다. 유전자 알고리즘의 배경 이론은 각 초평면의
점들의 새로운 분포가 모집단의 스트링의 평균 적합도에 따라 바뀌어야 한다
는 것이다. 이처럼 유전자 알고리즘이 특정한 초평면 부분을 외부적으로 평가
하는 것은 아니지만, 마치 그러한 것처럼 스트링 복사 분포를 변경시킨다.

  <표 ?>에 있는 예제 집단은 오직 21개의 스트링만 포함하고 있다. 이러한
스트링들에 대한 정확한 평가가 필요한 것은 아니기 때문에, 적합도를 순서에
따라 적절히 할당하였다. 이 표는 각 스트링의 적합도에 대한 정보와 임시 모
집단에 복사될 개체의 수를 포함한다. 이 예에서는 선택의 과정에서 생산된
복사의 수는 정수 부분을 할당함으로써 자동으로 결정한 것이다. 그 다음으로
0.0과 1.0사이의 난수를 발생시켜 소숫점 이하 부분을 할당하였다(이것은 나머
지의 확률적 샘플링의 형태이다). 난수가 (1-나머지)보다 크면, 해당하는 객체
에 추가적인 복사를 하였다.

  모집단에 대해 선택이 진행될 때, 유전자 알고리즘은 잠재적으로 많은 초평
면을 처리하는 것으로 나타난다. <표 ?>는 스트링의 처음 3비트에 의해서 정
의될 수 있는 27개(33)의 초평면을 열거하였다. 그리고 명시적으로 해당하는
초평면 부분과 연관된 적합도를 계산하였다. 초평면 부분들의 실제적인 적합
도는 그 초평면 부분에 속하는 모든 스트링의 적합도의 평균이어야 한다. 유
전자 알고리즘은 그 초평면 부분들의 적합도를 측정하는 표본으로써 모집단을
사용한다. 물론 표본이 임의적인 때는 처음 세대에서 뿐이다. 이 후에, 새로운
스트링의 샘플링은 이전 모집단에 대한 평균보다 높은 값을 가지는 스트링들
이 있는 영역으로 편중된다.

  유전자 알고리즘이 잘 실행되었다면, 선택후에 특정한 초평면에 남게되는
스트링의 개체수는 그 부분에 남아야 할 것이라고 예측된 값에 근사하다.

  <표 ?>에서 기대값(Expected Number)은 선택 전의 현재 모집단에 있는
특정 초평면 샘플의 수와 그 부분에 포함되는 스트링들의 평균 적합도를 곱하
여 구해진다. 선택에 의해 실제로 구해진 관측값(Observed Number)도 주어
져 있다. 대부분의 경우에 기대값과 관측값의 비율은 정확하다. 에러는 모
집단의 크기가 작아서 생긴 샘플링 에러의 결과이다.

  초평면 H의 잠재적인 샘플링 비율을 공식화해보자. M(H,t)를 어떤 모집단
의 현재 세대 t에서의, 초평면 H의 스트링 샘플의 수라고 하자.
(t+intermediate)를 선택후 - 그러나 교배와 돌연변이 전의 - 의 세대 t의
인덱스라 하자. f(H,t)를 현재 모집단의 부분 H에 속하는 스트링의 평균적합
도라 하자. 초평면 H에 속한 스트링과 관계된 적합도에 따른 표현의 변화는
다음과 같이 형식적으로 표현할 수 있다.

M(H, t + "intermediate")=M(H, t) { f(H,
t)} over { BAR f}.

  물론, 스트링이 단순히 복사되었을 때는, 새로운 샘플이 만들어진 것이 아니
기 때문에 새로운 초평면 샘플링이 일어난 것은 아니다. 하지만, 재조합과 돌
연변이 연산은 부분적으로는 임시 모집단에서 관찰된 초평면에 나타난 스트링
의 분포를 유지하면서, 새로운 샘플 점들을 생성하는 수단을 제공한다.

교배 연산자와 스키마타

  <표 ?>에 있는 초평면의 관찰된 표현은 선택후의 임시 모집단의 표현이므
로 재조합 이전의 상황이다. 재조합이 관찰된 스트링의 분포에 무엇을 하는
가? 명백하게 차수 1인 초평면 샘플들은 재조합에 영향을 받지 않는다. 왜냐
하면, 1개의 비트는 자손 중 양쪽 누군가에서 반드시 상속되기 때문이다. 그러
나, 하지만 차수가 2 이상인 초평면 부분들은 교배에 의해 영향을 받는다. 더
군다나, 차수가 같은 모든 초평면들이 같은 확률로 영향을 받는 것은 아니다.
1-점 교배를 생각해 보자. 초평면을 나타내는 다른 스키마타에 대한 영향을
측정하기가 상대적으로 쉽기 때문에 1-점 교배는 분석이 용이하다. 얘기를 간
단히 하기 위해, 12비트로 부호화된 스트링을 가지고 작업을 하고 있다고 하
자. 다음 2개의 스키마타를 고려해 보자.

11**********     and     1**********1

  첫 번째 스키마타가 1-점 교배에 의해서 분리된 확률은 가능한 교배점이
L-1 군데이기 때문에, 단지 1/L-1이다. 하지만 오른쪽의 두 번째 스키마타가
1-점 교배에 의해서 분리될 확률은 L-1군데의 모든 교배 점이 이 스키마타를
분리하기 때문에, (L-1)/(L-1), 즉 1.0이다. 이것은 일반적인 관찰결과를 유도
한다. 1-점 교배에 있어서 비트들의 위치는 교배 후에 그러한 비트들이 같이
남아 있을지를 결정하는 중요한 역할을 한다.

2-점 교배(2-point Crossover)

  2-점 교배를 적용하면, 어떤 일이 일어날까? 2-점 교배는 임의로 선택되어
진 2개의 점을 이용한다. 이러한 2점에 의해 구분된 스트링 조각들은 교환한
다. 2-점 교배는 스트링과 스키마타를 그들이 반지(Ring)를 이루고 있는 것
처럼 취급한다. 이것은 다음 그림처럼 설명할 수 있다.

                     b7  b6  b5             *  *  *

                    b8        b4          *         *

                   b9          b3       *             *

                    b10       b2          *         *

                     b11 b12 b1             *  1  1

<그림 ?> 2-점 교배

  여기서 b1와 b12는 각각 1과 12번째 비트를 의미한다. 이런 방식으로 관찰
했을 때, 1-점 교배는 2-점 교배의 특수한 한 경우에 속하는 데, 2개의 점 중
한 점은 반드시 첫 번째와 마지막 비트사이가 연결되는 점에서 일어난다. 이
번에는 차수 2인 스트링이 가장 크게 파괴될려면 2개의 비트가 링에서 반대되
는 곳에 위치해야 한다.

  1-점 교배나 2-점 교배에 있어서 확실한 것은 비트들이 서로 모여서 부호
화된 스키마타는 그렇지 않은 것에 비해 교배에 의해 파괴될 확률이 다소 적
다는 것이다. 더 정확하게 말하면, 초평면이 더 조밀한(Compact) 표현을 가
진 스키마타에 의해 표현되면, 단독으로 선택되었을 때의 분포 비율에 더 가
깝게 샘플링되는 것이다. 여기서 스키마타에 대해서 사용한, 조밀한 표현
(Compact Representation)은 교배중에 파괴될 확률이 적어진다란 의미이다.
이 정의는 연산자에 의존적이다. 앞의 예에서 보면, 차수가 2인 2개의 스키마
타가 2-점 교배에 대해서는 둘 다 조밀한 표현이었지만, 1-점 교배에서는 그
렇지 않았다.

연결(Linkage)과 정의 길이(Defining Length)

  연결(Linkage)은 그룹으로서 함께 상속되는 경향이 있는 "함께 적응된 형
질(Coadapted Alleles)" 처럼 행동하는 비트들의 현상을 말한다. 이 경우에 형
질은 염색체에서 특정 위치에 있는 비트 값을 의미한다. 물론, 연결은 스키마
타에관한 압축된 표현(Compact Representation with respect to
schema)의 개념의 일반적인 확장이라 할 수 있다. 연결은 가끔식 스트링 부
호화에서 비트들의 물리적인 가까움으로 정의된다. 여기서 묵시적으로 1-점
교배가 사용되었다고 가정된다. 2-점 교배하에서의 연결은 반지처럼 간주된
염색체 상에서의 거리에 관해 정의된다. 그럼에도 불구하고 보통, 연결은 정
의 길이(Defining Length)에 의해서 측정되는 것처럼, 스트링에서 물리적인
가까움처럼 평가된다.

  스키마타의 정의 길이는 값이 0 혹은 1인 - 즉 * 가 아닌 - 첫번째 비트와
마지막 비트 사이의 거리에 근거한다. 스키마타의 각 위치에 0,1 혹은 *가 주
어졌을때, 왼쪽에서 오른쪽으로 스캔한다. Ix를 가장 오른쪽에 위치한 0 혹은
1의 위치이고, Iy를 가장 왼쪽에 위치한 0 혹은 1의 위치라고 하면, 정의 길이
는 Ix - Iy이다. 예를 들면 ****1**0**10**의 정의 길이는 12-5=7이다. 초평면
H를 대표하는 스키마타의 정의 길이를 Ԥ(H)처럼 나타낸다. 정의 길이는 얼마
나 많은 교배가 스키마타의 중요한 부분에 속하는지를 측정하는 직접적인 단
위이다. 만약 1-점 교배가 사용되었다면, 교배가 스키마타의 중요한 부분에
속하는 직접적인 측정값은 Ԥ(H)/L-1이다.

연결과 도치(Linkage and Inversion)

  교배와 돌연변이와 함께, 도치(Inversion)는 종종 기본적인 유전 연산자로
간주된다. 도치는 염색체의 비트들의 연결을 바꿀 수 있다.

  전통적으로 도치는 염색체의 임의의 부분들을 반전시킴으로써 구현한다. 그
러나, 연결을 개선시키기 위해 염색체의 비트들을 옮기기 전에, 먼저 비트들은
위치에 상관없는 부호화가 되어 있어야 한다. 몇 연구가들이 처음으로 도치를
구현할 때 저지르는 일반적인 실수는 직접적으로 부호화된 염색체의 일부를
반전시키는 것이다. 하지만, 비트들이 위치에 의존적으로 부호화되었다면, 임
의의 부분을 반전시키는 것은 큰 규모의 돌연변이와 다를게 없다.

  위치에 상관 없는 부호화는 각각의 비트가 어떠한 형태로든 꼬리표(Tag)될
것을 필요로 한다. 예를 들면, 첫번째 숫자가 비트의 위치를 나타내고, 두번째
숫자가 비트 값을 의미하는 아래와 같이 부호화된 스트링을 고려해보자.

((9 0) (6 0) (2 1) (7 1) (5 1) (8 1) (3 0) (1 0) (4 0))

  이제 연결은 꼬리표-비트 쌍을 함께 이동시킴으로 변경이 가능하다. 그러나
스트링은 복호화(Decode)되었을 때 010010110으로 동일하다. 재조합을 어떻게
구현할지도 물론 고려해보아야 한다. Goldberg 와 Bridges, Whitley 와
Holland는 꼬리표된 표현에서 연결과 재조합에 대한 문제에 대해 논의하고 있
다.

스키마 이론(Schema Theorem)

  이제 유전자 알고리즘의 기본적인 이론을 접할 기초가 다져졌다. 스키마 이
론은 t세대에서 t+1세대로의 한 개의 초평면에 대한 샘플링 비율의 변화에 대
한 낮은 한계(Lower Bound)를 제공한다.

  선택만이 있어났을 때 특정한 초평면 H에 무엇이 일어나는지 고려해보자.

M(H,t+"inter"mediate) = M(H,t) {f(H,t)}
over {bar f}.

  M(H,t+1)을 계산하기 위해서, 임시 모집단에서 다음 모집단을 생성할 때의
교배에 대해서 고려해야 한다. 먼저 모집단의 일부에 대해 확률적으로 교배가
적용되었다고 하자. 교배가 적용되지 않은 그 부분에 대해, 선택에 의한 표현
은 바뀌지 않았다. 교배가 일어났을 때, 우리는 교배의 파괴적인 영향에 의한
손실을 계산에 넣어야 한다.

M(H,t+1)=(1-p sub c ) M(H,t) {f(H,t)} over {bar f} + p sub c [M(H,t)
f(H,t) over {bar f} (1-losses)+gains]

  스키마 이론을 도출하는 과정의 이 시점에서 한가지 보수적인 가정을 한다.
가정은 '스키마의 정의 길이안에서 발생한 교배는 초평면 H를 나타내는 스키
마를 항상 파괴한다.' 이다. 실제로, 이것은 사실이 아니며 자세한 계산은 뒤에
제시할 것이다. 예를 들면, 스키마 11*****를 고려해 보자. 1110101이 두 개의
스트링 1000000와 0100000의 앞쪽 두 비트 사이에서 일어난 교배로 재조합되
었다면, 초평면 11*****는 파괴된 것이 아니다. 왜냐하면 자손이 모두 이 스
키마의 부분에 속하기 때문이다. 또한 1000000와 0100000가 정확하게 첫째와
둘째 비트 사이에서 재조합되었다면, 새로운 독립적인 자손이 11*****를 샘플
링할 것이므로, 이것은 위 식에서 언급된 gains의 원천이다. 식을 간단히 하기
위해, 일단 gains를 무시하며, 보수적인 가정을 사용한다. 그러므로,

M(H,t+1) ≥ (1-p sub c ) M(H,t) {f(H,t)} over {bar f} + p sub c
[M(H,t) f(H,t) over {bar f} (1-disruptions)]

여기서 disruptions는 losses의 과측정치(Overestimate) 이다. 우리는 한가지
예외를 고려하기를 원할지 모른다: '만약 양쪽 스트링 모두가 H에 속하는 두
스트링이 재조합되었다면, 파괴는 일어나지 않는다.' P(H,t)를 M(H,t)를 모집
단의 크기로 나누어서 얻어지는 H의 비율적인 표현이라 하자. 임의로 고른
짝(Mate)이 H를 샘플할 확률은 P(H,t)이다. Ԥ(H)가 1-점 교배에서의 정의 길
이라고 하였으므로, 파괴는 다음과 같은 식으로 주어진다.

DELTA(H) over
{L-1} (1-P(H,t)).

  이 시점에서 부등호는 간단히 될 수 있다. t+1세대에서의 H의 비율적인 표
현인 P(H,t+1)에 관한 식으로 나타내기 위하여, 식의 양변을 모집단의 크기로
나눈다. 그리고 Pc에 대해 식을 정리하면 다음과 같다.

P(H,t+1) ≥ P(H,t) f(H,t) over { bar f}[1-p sub c
DELTA(H) over {L-1} (1-P(H,t))]

  우리는 마침내 스키마 이론의 유용한 식을 - 아직 돌연변이는 고려하지 않
고 있지만 - 유도했다. 하지만 이것이 다는 아니다. 예를 들면, 양쪽 부모는
전통적으로 적합도에 기준하여 선택되었다. 이것이 스키마 이론의 공식에 추
가될 수 있다.

P(H,t+1) ≥ P(H,t) f(H,t) over { bar f}[1-p sub c
DELTA(H) over {L-1} (1-P(H,t) f(H,t) over { bar f})]

  마침내 돌연변이를 고려해야 한다. o(H)가 초평면의 차수를 리턴하는 함수
라고 하자. H의 차수는, 초평면 H를 나타내는 스키마의 0 혹은 1값을 가지는
비트의 수와 정확히 대응한다. 돌연변이가 항상 비트를 변화시키며 확률을 pm
이라 하자. 그러면 H를 표현하는 스키마에 돌연변이가 끼칠 확률은 (1-pm)o(H)
이다. 이것은 스키마 이론에서 다음과 같은 식을 유도한다.

P(H,t+1) ≥ P(H,t) f(H,t) over { bar f}[1-p sub c DELTA(H) over
{L-1} (1-P(H,t) f(H,t) over { bar f})](1-p sub m ) sup {o(H)}

교배, 돌연변이, 이른 수렴(Crossover, Mutation and Premature
Convergence)

  명확히 스키마 이론은 유전자 알고리즘에서 초평면 샘플링과 교배의 역할을
대단히 강조하고 있다. 선택후에 초평면 샘플들의 보존을 극대화하기 위해, 교
배와 돌연변이의 파괴적인 효과는 최소화 되어야 한다. 이것은 돌연변이가 아
예 사용되지 않거나, 아주 낮은 수준으로 사용되어야 함을 제시한다.

  돌연변이를 사용하는 동기는, 어떤 특정한 비트 혹은 형질을 영원히 잃어
버리는 것을 방지하기 위해서이다. 몇 세대후에 선택에 의해서 몇 위치의 모
든 비트들을 0 혹은 1로 만드는 것이 가능하다. 유전자 알고리즘이 만족할만
한 결과에 수렴하지 않았을 때, 이러한 일이 일어나면, 알고리즘은 이른 수렴
(Premature Convergence)을 한 것이다. 이것은 작은 모집단을 가지고 실험
을 수행하고 있었다면 문제가 될 수 있다. 돌연변이 연산 없이, 빠진 비트 값
을 다시 회복하는 것은 불가능하다. 또한 대상 함수가 부동적이지 않고, 적합
도의 전망이 시간에 따라 변한다면, 유전적인 다형성을 유지해야 할 어떤 소
스가 필요하다. 그러므로 돌연변이는 배경 연산으로서 동작한다. 가끔식 비트
값을 바꾸며, 가능성 있는 다른 형질이 재검사되는 것을 허용한다.

  돌연변이에 대한 이런 특별한 해석은 언덕 오르기 방법(Hill-climbing
Method)으로서의 그것의 잠재적인 특성을 무시한다: 스키마 이론에 의해서
부과된 엄격한 초평면 샘플링의 관점에서 보면, 돌연변이는 필요 악
(Necessary Evil)이다. 그러나 이것은 아마도 제한된 관점이다. 교배를 사용하
지 않고 돌연변이만을 사용한 유전자 탐색이 좋은 결과를 얻었다는 몇 실험
결과가 있다. 그리고 유전자 탐색에서 언덕 오르기와 초평면 샘플링의 상호관
계에 대해 언급한 이론은 거의 없다.

  이른 수렴과 관계된 또 다른 문제점은 모집단의 적합도를 스케일링
(Scaling) 하는 것의 필요성에 관한 문제이다. 모집단에서의 스트링에 대한
평가의 평균이 증가함에 따라, 적합도의 편차는 줄어든다. 몇 세대가 지나면
가장 좋은 개체와 가장 나쁜 개체사이에는 거의 차이점이 존재하지 않을 수도
있다. 그리고, 적합도에 근거한 선택의 압력은 자연히 줄어든다. 이러한 형태
의 문제는 적합도를 스케일링하여 사용하는 것으로 몇 군데서 언급되어 있다.
서열에 기준한 선택등이 사용될 수 있다.

재조합이 어떻게 초평면 사이를 움직이는가

  1-점 교배의 좋은 점은 분석적으로 모델링하기에 쉽다는 것이다. 그러나,
스키마 파괴를 최소화하는데 흥미가 있다면, 2-점 교배가 유리하다는 것을 쉽
게 보여줄 수 있다. 그러나 많은 교배점을 사용하는 연산은 피해야 한다. 왜냐
하면, 스키마의 극단적인 파괴 때문이다. 이것은 다시 스키마 이론의 정확한
분석을 필요로 한다. 반면에, 파괴가 유전자 알고리즘의 수행에 영향을 미치는
유일한 요소가 아닐 수도 있다.

균일한 교배(Uniform Crossover)

  최근에 가장 주목 받은 연산자는 균일한 교배(Uniform Crossover)이다.
균일한 교배는 Ackley에 의해 자세히 연구되었으며, Syswerda에 의해 유명하
게 되었다. 균일한 교배는 다음과 같이 동작한다. L의 각각의 비트 위치 l에
대해, 두 개의 부모 스트링 중 한 곳에서 임의로 한 비트를 가져온다. 이것은

그림 4:

각각의 비트가 다른 비트와는 독립적으로 상속되며, 사실상 비트들 사이에 연
결(Linkage)이 없는 것이 된다. 그것은 또한 균일한 교배가 정의 길이
(Defining Length)에 치우치지 않는다는 의미이다. 일반적으로 파괴의 확률은
1-(1/2)o(H)-1이다. 여기서 o(H)는 우리가 관심있는 스키마의 차수이다(어떤 자
식이 첫 결정적인(Critical) 비트를 상속받는지는 문제가 아니다. 다만, 다른
모든 비트들은 같은 자손에게 상속되어야 한다. 이것은 또한 흥미있는 스키마
에서 발견되는 어떠한 형질도 부모에 의해 공유되지 않는다는 가정하에서 파
괴의 최악의 경우의 확률이다). 이처럼 어떠한 차수 3의 스키마타에 대해서도
결정적인 비트들을 분리하는 균일한 교배의 확률은 1-(1/2)2=0.75이다. 잠시 9
비트의 스트링을 고려해보자. 1-점 교배의 파괴적인 확률이 균일한 교배의 확
률 6/8=0.75와 같아지기 위해서는 스키마의 정의 길이가 6이라야 한다. 우리는
특정한 9비트 스트링에 대해 84개의 서로 다른 차수 3인 스키마타를 정의할
수 있다(9C3=84). 이 스키마타에서, 84개의 2 차수 스키마타에서 오직 19개만
이, 1-점 교배에서 0.75보다 높은 파괴 비율을 가진다. 다른 15개는 완전히 같
은 파괴 비율을 가지고, 50개는 보다 낮은 파괴 비율을 가진다. 일관적 교배가
정의 길이에 대해서는 치우치지 않았지만, 1-점 교배보다는 일반적으로 더 파
괴적이라는 것을 보여주는 것은 쉬운 일이다. Spears와 DeJong은 모든 정의
길이의 차수 3인 스키마타에 대해, 모든 경우에 일관적 교배가 2-점 교배보다
더 파괴적이라는 것을 보였다.

  분석적인 결과에도 불구하고, 몇몇 연구가들은 일관적인 교배가 가끔식은
좋은 재조합 연산자라는 것을 제시하였다. 가능한 설명으로 누군가는 스키마
파괴에 대해 표현적인 치우침의 부족을 지적할 수 있다. 그러나 이것은 일관
적인 교배가 2-점 교배보다 일관적으로 나쁘기 때문에 아니다. Spears와
DeJong은 다음과 같이 명시하고 있다. "작은 모집단으로 일관된 교배나 n-점
교배와 같은 더욱 더 파괴적인 교배 연산자가 더 좋은 결과를 얻을 수 있다.
왜냐하면, 작은 모집단의 제한된 정보량과 동질성의 경향을 극복하기 때문이
다." Eshelman은 파괴적인 연산자의 이점에 대해서 같은 논쟁을 벌이고 있다.

  일관된 교배가 치우치지 않았다는 다른 면이 있다. 0000과 1111을 재조합한
다고 가정해 보자. 우리는 편하게 <그림 ?>와 같은 4차원 초육면체를 생각해
볼 수 있다. 우리는 또한 이 스트링들이 초육면체를 통하여 최소 경로의 집합
에 의해 연결되어 있다고 볼 수 있다. 시초로 한 부모 스트링을 잡고 다른 한
쪽을 목적지로 잡을 수 있다. 이제 시초로 잡은 점에 대응하는 이진 표현에서
한 비트를 바꾸어 보자. 그러한 어떤 움직임도 목적지에 가까이 가는 움직임
이 된다. <그림 ?>에서 보듯이 한 비트를 바꾸면 그래프에서 위로 혹은 아래
로 움직이게 된다.

  0000과 1111 사이에 있는 모든 점들은 일관된 교배에 의해 도달할 수 있다.
그러나, 1-점 교배는 두개의 이러한 4차원 초육면체에서, 보충적인 경로 - 그
림에서 가장 왼쪽 경로와 가장 오른쪽 경로 - 를 따라 있는 스트링만 생성한
다. 일반적으로, 일관된 교배는 두개의 부모 사이의 초육면체에서 모든 상호
보충적인 최소 경로를 따라 있는 모든 점들로부터 같은 확률로 샘플 점들의
보충적인 쌍들을 나타낼 수 있다. 반면에 1-점 교배는 두개의 부모 사이에 2
개의 특정한 보충적인 최소 경로만을 샘플한다. 2-점 교배가 1-점 교배 보다
덜 제한적이라는 것을 보이는 것도 쉽다. 두 개의 스트링 사이에서 서로 다른
비트들의 수가 해밍 거리(Hamming Distance) H라는 것을 상기하자. 원래의
부모 스트링을 포함하지 않고도, 일관된 교배는 2H-2개의 다른 스트링을 생성
할 수 있다. 하지만 1-점 교배는, 유일한 자손을 생성하는 교배 위치가 H개
있고, 각각의 교배는 2개의 자손을 생성하므로, 2(H-1)의 다른 스트링을 생성
할 수 있다. 2-점 교배는, 부모의 복사가 아닌 자손을 얻기 위해, H에서 2개
를 고르는 조합의 수만큼 교배점을 선택할 수 있고, 교배점의 각각의 쌍은 2
개의 스트링을 생성하므로, 2×HC2=H2-H개의 다른 자손을 생성할 수 있다.

줄여진 대리인(Reduced Surrogates)

  아래의 두 스트링을 교배하는 것을 고려하고 두개의 스트링이 공통으로 공
유하는 비트들을 제거한 형태의 "줄여진" 버전을 고려해 보자.

0001111011010011    ----11---1-----1

0001001010010010    ----00---0-----0

  두 개의 스트링은 모두 초평면 0001**101*01001*에 위치한다. 이 관찰의 이
면은, 교배가 서로 다른 비트 위치에 대해 정의된 부평면에 대해서만 제한된
것이라는 것이다. 우리는 두 부모 구조에서 같은 비트들 전부를 제거함으로써
이러한 부평면을 고립시킬 수 있다. Booker는 ----11---1-----1나
----00---0-----0같은 스트링을 원래의 부모 염색체의 줄여진 대리인이라
고 언급하고 있다.

  이러한 방식으로 보았을 때, 이 특별한 스트링에 대한 재조합은 앞에서 살
펴본 예와 비슷하게, 4차원 부평면상에서 일어난다. 일관된 교배는 두개의 부
모 스트링 사이에 정의된 부평면안에서 상보적인 최소의 경로를 따라 있는 모
든 점들의 쌍으로부터, 치우치지 않고 일관된 형태로 샘플한다는 면에서 일관
된 교배는 이러한 부평면에 치우치지 않는다. 반면에 간단한 1-점 교배와 2-
점 교배는 그렇지 않다. 이러한 생각의 설명을 돕기위해, 우리는 원래의 스트
링을 재조합하면서, 그들의 줄여진 형태의 자손을 관찰할 것이다. 예를 들면,
간단한 1-점 교배는 ----11---1-----0와 -----00---0-----1를 6/15의 확
률로 생성한다. 왜냐하면, L-1=15이며, 원래의 부모 스트링 사이에는 줄여진
부평면의 세번째와 네번째 비트 사이에 6개의 교배점이 있기 때문이다. 반면
에 ----10---0-----0와 ----01---1-----1는 1/15의 확률로 샘플된다. 왜냐
하면, 부평면을 정의하는 첫번째와 두번째 비트 사이에 속하는, 원래의 부모
구조의 교배점은 단지 1개이기 때문이다.

  그러나 이러한 특별한 치우침은 제거할 수 있다. 우리는 줄여진 대리인에
대해 교배를 적용할 것이다. 이제 교배는, 줄여진 대리인의 형태에 나타나는
의미있는 비트들 사이에 오직 1개의 교배점만이 존재한다는 사실을 탐험
(Exploit)할 것이다. 또다른 잇점이 있다. 줄여진 대리인의 첫번째와 마지막 의
미있는 비트들 사이에 최소한 1개의 교배점이 위치한다면, 자손은 부모의 복
사가 아니라는 보장이 된다. 이처럼 초공간의 새로운 샘플 점들이 생성된다.

  2-점의 줄여진 대리인과 같은 일관된 교배와 연산의 장점에 관한 논쟁은
끝난것이 아니다. 초평면 샘플링, 모집단의 크기, 이른 수렴, 교배 연산자, 유
전적 다양성과 돌연변이에 의한 언덕 오르기의 역할들 사이의 상호관계를 이
해하기 위해서는 보다 나은 분석적인 방법을 필요로 한다.

  9.5 이진 알파벳의 예

  최소 이진 알파벳(Minimal Binary Alphabet)의 사용의 뒤에 있는 동기는,
비교적 간단한 헤아리기 논쟁(Counting Argument)에 기초한다. 최소 알파벳
은 스키마 처리를 위한 부호화에 직접 사용가능한, 초평면 부분의 수를 극대
화한다.


10. L-시스템(L-system)

  이 장은 독자들이 문맥 자유 문법(context free grammar)을 알고 있다고 가
정한다. 이 장의 내용을 진행하기 전에 먼저 'CFG'를 읽어보는 것이 많은 도
움이 될 것이다.

  Lindenmayer는 식물의 성장을 모의 실험(simulation)하기 위해 체계적으로
연구하였다. 그는 형식적인 문법으로 식물의 성장을 어느 정도 모의 실험할
수 있음을 알았다. 후에 사람들은 그가 개발한 시스템 L-시스템(system)이
라 불렀다.

  형식적 정의(formal definition)

yet specified...

  예(example)

  아래 그림에 모두 9개의 문법 규칙(rewriting rule)을 가지는 L-system이
나타나 있다.

             Lindenmayer System: Grammar의 반복적인 적용은 스트링을 생성한다. 이
          스트링의 특별한 해석은 성장하는 나무를 모의 실험한다.

             스트링의 특별한 해석은 나무를 그리도록 한다. 특수한 심벌을 도입하면
          가지의 색, 굵기, 잎 등을 그릴 수 있다.

  시작 심벌 1에서 규칙을 적용하여 스트링을 생성한다. 1번 적용하면 1→23
에 의해 "23"이 생성된다. 2번째 적용하면 스트링을 이루는 각각의 심벌 2와
3에 대해, 2→2, 3→24가 적용되어 "224"가 생성된다. 3번째 적용하면 2→2, 4
→25가 적용되어 "2225"가 생성된다. 4번째 적용하면, 2→2, 5→65가 적용되어
"22265"가 생성된다. 5번째는 "222765"가 생성(2→2, 6→7, 5→65)되며, 6번째는
"2228765"가 생성(2→2, 7→8, 6→7, 5→65)된다. 7번째는 "2229[3]8765"가 생성
(8→9[3]의 추가 적용)된다. 마지막에 나타난 스트링은 이렇게 규칙을 14번 적
용한 것이다.

  그림의 오른쪽 나무는 이 스트링을 해석하여 그림으로 나타낸 것이다. 스트
링을 해석하는 방법은 다음과 같다.

    ■ 현재 꺽는 쪽: 스트링을 해석하기 위해 전체적으로 현재 꺽는 쪽
(왼쪽 혹은 오른쪽)을 유지한다.

    ■ 숫자: 단위 길이 1인 나무의 가지를 나타낸다. 가지의 길이를 조
정하기 위해 1에서 9까지의 숫자가 사용되었다. 즉 숫자가 가지의 색이나 굵
기를 나타내는 것은 아니다.

    ■ [: 나무 가지의 진행 방향이 원래 진행 방향에서 현재 꺽는 쪽(왼
쪽 혹은 오른쪽)으로 꺽임을 나타내며, 현재의 위치 및 진행 방향이 저장되어
야 함(스택에)을 나타낸다.

    ■ ]: 가장 최근의 [ 위치로 돌아가서(스택에서 팝(pop)한다) 가지의
원래 진행 방향대로 진행하며, 현재 꺽는 쪽을 토글(toggle)한다.

  229[229[24]9[3]8765]9[229[3... 을 해석해 보자.

    ① 현재 진행 방향을 90° 현재 꺽는 쪽을 왼쪽으로 설정한다.

② 229[229[24]9[3]8765]9[229[3...

    ② 229까지 진행 후 [를 만나면, 현재 상태(현재 위치, 90°,왼쪽)를
스택에 저장한다. 현재 진행 방향을 135°로 설정한다(90°에서 왼쪽으로 4
5°꺽어 진행할 준비를 한다).

             229[229[24]9[3]8765]9[229[3...의 스택 상태

③ 229[229[24]9[3]8765]9[229[3...

    ③ 229를 135°로 진행한다. 다시 [를 만나므로 현재 상태(위치, 13
5°, 왼쪽)을 스택에 저장한다. 현재 진행 방향을 180°로 설정한다.

             229[229[24]9[3]8765]9[229[3...의 스택 상태

④ 229[229[24]9[3]8765]9[229[3...

    ④ 180°로 24를 진행한 후, ]를 만나면, 스택에서 저장된 정보를 팝
한다. (현재 위치, 135°, 왼쪽)이 팝된다. 현재 꺽는 쪽을 토글한다(왼쪽에서
오른쪽이 된다).

             229[229[24]9[3]8765]9[229[3...의 스택 상태

⑤ 229[229[24]9[3]8765]9[229[3...

    ⑤ 135°로 9를 진행한 후 [를 만나면 (현재 위치, 135°, 오른쪽)을
저장한다. 현재 진행 방향을 135°에서 오른쪽으로 45°인 90°로 설정한다.

             229[229[24]9[3]8765]9[229[3...의 스택 상태

⑥ 229[229[24]9[3]8765]9[229[3...

    ⑥ 90°로 3을 진행한 후, ]를 만나면 스택에서 저장된 정보를 팝한
다. (현재 위치, 135°, 오른쪽)이 팝된다. 현재 꺽는 쪽을 토글한다(오른쪽에
서 왼쪽이 된다).

⑦ 229[229[24]9[3]8765]9[229[3...

    ⑦ 135°로 8765를 진행한다. ]를 만나면 스택에서 저장된 정보를 팝
한다. (현재 위치, 90°, 왼쪽)이 팝된다. 현재 꺽는 쪽을 토글한다(왼쪽에서
오른쪽으로 설정된다).

⑧ 229[229[24]9[3]8765]9[229[3...

    ⑧ 90°로 9를 진행한다. [를 만나면 (현재 위치, 90°, 오른쪽)을 스
택에 푸시(push)한다. 현재 진행 방향은 90°에서 오른쪽으로 45°꺽인 45°
가 된다.

⑨ 229[229[24]9[3]8765]9[229[3...

    ⑨ 45°로 229를 진행한다. [를 만나면 (현재 위치, 45°, 오른쪽)을
스택에 푸시한다. 현재 진행 방향은 다시 오른쪽으로 45° 꺽인 0°가 된다.

  이하 나머지 부분을 규칙에 따라 진행하면 문법에 의한 나무가 그려진다.

단계 1(step 1)

  이제 단계별로 코드를 작성해 보기로 하자. 먼저 필요한 함수는 나무의 가
지를 그리는 함수이다. 이 함수는 현재의 위치 (x, y), 그릴 방향과 길이가 주
어지면, 선을 그려야 한다.

  현재 위치와 선의 길이, 그리고 각도가 주어졌을 때 선을 그리는 함수는
'화살표 그리기'에서 언급한 바 있다. 그 그림을 다시 참조해 보자.

             (xStart,yStart)와 각도 nDegree 그리고 선의 길이 nLength가 주어지면 선의
          폭 h와 높이 w를 계산할 수 있다. 그러면 선의 다른 한쪽 끝점
          (xArrow,yArrow)를 구할 수 있다.

  위의 그림에서 (xStart, yStart), nLength와 nDegree가 주어진다. 삼각함수
공식을 이용하면 선의 반대 위치인 (xArrow, yArrow)를 구할 수 있다. 구현
할 함수는 PolarLine()이다. 함수의 프로토타입은 다음과 같다.

static void PolarLine(int x,int y,int degree,int length,

        int& xEnd,int& yEnd);

  (x,y)는 선의 시작점을 명시한다. degree는 각도를 명시하며, length에는 선
의 길이를 명시한다. xEnd와 yEnd에는 계산된 선의 반대편 끝 좌표가 넘어온
다. 함수의 소스는 아래와 같다.

inline double ToRadian(int degree) {

    return (double)degree*M_PI/180.0;

      //                  ^defined in math.h

}//ToRadian

void PolarLine(int x,int y,int degree,int length,

               int& xEnd,int& yEnd) {

    int h,w;//height,width

    w=(int)(cos(ToRadian(degree))*(double)length);

    h=(int)(sin(ToRadian(degree))*(double)length);

    xEnd=x+w; yEnd=y-h;//x축에 대해 대칭 이동해야 한다.

    line(x,y,xEnd,yEnd);

}//PolarLine

  함수에서는 먼저 선의 폭 w와 높이 h를 구한다. 그리고 (xEnd, yEnd)를 구
하기 위해 (x+w, y+h)를 수행한다. 소스에서는 y좌표를 구하기 위해 y-h를
사용한 사실에 주의하라. 화면 좌표계는 수학에서 사용하는 좌표계와 y축의
방향이 반대이므로(x축에 대칭된 형태이므로) y좌표에 대해서는 h를 빼 주어
야 원하는 결과가 얻어진다.

  ToRadian()은 각도를 라디안(radian)으로 바꾸는 일을 한다. math.h에 선언
된 상수 M_PI는 π의 상수 값이다. 어떤 컴파일러는 이 상수가 선언되어 있
지 않으므로 직접 선언해서 써야한다(Visual C++ 컴파일러의 math.h에는
M_PI가 선언되어 있지 않다). π는 math.h에 다음과 같이 선언되어 있다.

...

#define M_E         2.71828182845904523536

#define M_LOG2E     1.44269504088896340736

#define M_LOG10E    0.434294481903251827651

#define M_LN2       0.693147180559945309417

#define M_LN10      2.30258509299404568402

#define M_PI        3.14159265358979323846

#define M_PI_2      1.57079632679489661923

#define M_PI_4      0.785398163397448309616

#define M_1_PI      0.318309886183790671538

#define M_2_PI      0.636619772367581343076

#define M_1_SQRTPI  0.564189583547756286948

#define M_2_SQRTPI  1.12837916709551257390

#define M_SQRT2     1.41421356237309504880

#define M_SQRT_2    0.707106781186547524401

...

  좌표를 모두 구한 다음 BGI 선그리기 함수 line()을 이용해 선을 그린다.

  아래의 소스는 현재의 위치 (xCurrent, yCurrent)와 현재 그리기 방향
dirCurrent와 현재 선의 길이 lenCurrent, 그리고 계산된 새로운 위치 (xNew,
yNew)를 클래스 데이터 멤버로 가지는 CGraphic의 소스이다.

  PolarLine()외에 다른 특별한 사항은 없다. PolarLine()은 클래스와 독립적으
로 동작하는 함수이므로 static으로 선언되었으며, SetPosition()은 현재 위치
뿐만 아니라, 방향과 길이도 디폴트 파라미터로 받고 있다. -360은 지정될 일
이 없으므로, SetPosition()의 세번째, 네번째 파라미터가 명시되지 않으면, 현
재 방향과 길이를 그대로 사용한다.

  main()에서는 (200,200)에서 시작하는 길이 100의 선을 45°로 그리고 있다.

#include <conio.h>

#include <math.h>

#include <graphics.h>

inline double ToRadian(int degree) {

    return (double)degree*M_PI/180.0;

      //                  ^defined in math.h

}//ToRadian

class CGraphic {

    int xCurrent,yCurrent,dirCurrent,lenCurrent;

    int xNew,yNew;

  public:

    CGraphic() { xCurrent=yCurrent=dirCurrent=lenCurrent=0; }

    static void PolarLine(int x,int y,int degree,int length,int&

        xEnd,int& yEnd);

    void SetPosition(int x,int y,int dir=-360,int len=-360);

    void SetDir(int dir) { dirCurrent=dir; }

    void SetLen(int len) { lenCurrent=len; }

    void DrawStraightLine();

};//class CGraphic

void CGraphic::PolarLine(int x,int y,int degree,int length,

                        int& xEnd,int& yEnd) {

    int h,w;//height,width

    w=(int)(cos(ToRadian(degree))*(double)length);

    h=(int)(sin(ToRadian(degree))*(double)length);

    xEnd=x+w; yEnd=y-h;

    line(x,y,xEnd,yEnd);

}//CGraphic::PolarLine

void CGraphic::SetPosition(int x,int y,int dir,int len) {

    xCurrent=x;

    yCurrent=y;

    if (dir!=-360) dirCurrent=dir;

    if (len!=-360) lenCurrent=len;

}//CGraphic::SetTurtle

void CGraphic::DrawStraightLine() {

    PolarLine(xCurrent,yCurrent,dirCurrent,lenCurrent,xNew,yNew);

}//CGraphic::DrawStraightLine

void main() {

    int gd=0,gm;

    initgraph(&gd,&gm,"f:\\bc45\\bgi");

    CGraphic t;

    t.SetPosition(200,200,45,100);

    t.DrawStraightLine();

    getch();

    closegraph();

}//main

             검은색 실선이 45°로 그려졌다.

단계 2(step 2)

  이제 선의 굵기 nThickness와 색 nColor를 클래스의 데이터 멤버로 추가해
보자. 이것은 매우 간단한 작업이며, 선의 굵기를 설정하기 위해 setlinestyle()
함수를 사용하고 있으며, 선의 색을 설정하기 위해 setcolor()함수를 사용하고
있다. 또한 BGI함수와 멤버 함수를 구분하기 위해 BGI함수의 앞에 범위 해결
사(scope resolver) 연산자를 사용하고 있다■.

             C++에서는 멤버 함수가 아닌 경우 함수의 이름 앞에 범위 해결사를 사용
          할 수 있다. 또한 멤버 함수와 전역 함수의 이름이 충돌하는 경우 이를 구
          분하기 위해 전역 함수의 앞에는 범위 해결사를 사용한다.

  setcolor()의 파라미터는 0에서 15까지의 상수 값을 가지면, 이것은 BLACK
등의 상수로 graphics.h에 선언되어 있다. setlinestyle()의 첫 번째 파라미터는
0에서 12까지의 상수로 SOLID_LINE■등의 상수로 선언되어 있고, 세 번째 파

             이 외의 매크로 상수는 도움말 기능을 이용하면 쉽게 확인할 수 있다.
          Borland C++ 3.x의 경우는 단어 위헤 커서를 위치시키고 ^F1을 누르고,
          Borland C++ 4.x이상인 경우 F1을 누르면 현재 커서가 위치한 단어의 도움
          말을 보여 준다. 이러한 도움말 시스템을 문맥에 민감한 도움말(context
          sensitive help)이라 한다.

라미터는 1 혹은 3인데 이것은 각각 NORM_WIDTH, THICK_WIDTH로
graphics.h에 선언되어 있다.

  단계 1에서 바뀌어진 소스는 밑줄을 그어 명시하였다.

#include <conio.h>

#include <math.h>

#include <graphics.h>

inline double ToRadian(int degree) {

    return (double)degree*M_PI/180.0;

      //                  ^defined in math.h

}//ToRadian

class CGraphic {

    int xCurrent,yCurrent,dirCurrent,lenCurrent;

    int xNew,yNew;

    int nThickness,nColor;

      //nThickness: NORM_WIDTH(1) or THICK_WIDTH(3) by BGI graphic

      // library

      //nColor: from BLACK(0) to WHITE(15) by BGI graphic library

  public:

    CGraphic() { xCurrent=yCurrent=dirCurrent=lenCurrent=0; }

    static void PolarLine(int x,int y,int degree,int length,int&

                          xEnd,int& yEnd);

    void SetPosition(int x,int y,int dir=-360,int len=-360);

    void SetDir(int dir) { dirCurrent=dir; }

    void SetLen(int len) { lenCurrent=len; }

    void SetThickness(int t) { nThickness=t; }

    void SetColor(int c,int t=0);

    void DrawStraightLine();

};//class CGraphic

void CGraphic::PolarLine(int x,int y,int degree,int length,

                        int& xEnd,int& yEnd) {

    int h,w;//height,width

    w=(int)(cos(ToRadian(degree))*(double)length);

    h=(int)(sin(ToRadian(degree))*(double)length);

    xEnd=x+w; yEnd=y-h;

    ::line(x,y,xEnd,yEnd);

}//CGraphic::PolarLine

void CGraphic::SetPosition(int x,int y,int dir,int len) {

    xCurrent=x;

    yCurrent=y;

    if (dir!=-360) dirCurrent=dir;

    if (len!=-360) lenCurrent=len;

}//CGraphic::SetTurtle

void CGraphic::SetColor(int c,int t) {

    nColor=c;

    if (t!=0) nThickness=t;

}//CGraphic::SetColor

void CGraphic::DrawStraightLine() {

    ::setlinestyle(SOLID_LINE,0,nThickness);

      //           ^defined in graphics.h

    ::setcolor(nColor);

    PolarLine(xCurrent,yCurrent,dirCurrent,lenCurrent,xNew,yNew);

}//CGraphic::DrawStraightLine

void main() {

    int gd=0,gm;

    initgraph(&gd,&gm,"f:\\bc45\\bgi");

    CGraphic t;

    t.SetPosition(200,200,45,100);

    t.SetColor(GREEN,THICK_WIDTH);

      //       ^     ^defined in graphics.h

    t.DrawStraightLine();

    getch();

    closegraph();

}//main

  실행 결과는 아래와 같다.

             굵기가 3픽셀의 초록색 선이 45°로 그려졌다.

단계 3(step 3)

  이제 필요한 기초 작업은 모두 끝이 났다. 이제 문법을 스트링으로 변환시
키는 부분을 만들어 보자.

  먼저 CGrammar 클래스는 문법을 유지하기 위해 strGrammar[]라는 2차원
문자 배열을 사용한다. 이 배열에는 L-system을 구성하는 문법이 스트링의
형태로 들어 있다. 문법을 이 배열에 설정하는 함수는 SetGrammar()인데 가
변 인자를 사용하고 있다. 이 부분은 '가변 인자'에서 설명하였으므로 여기서
는 설명하지 않는다.

  CGrammar는 스트링을 생성하기 위해 2개의 버퍼를 유지한다. 2개의 버퍼
는 알고리즘 동작 중에 source와 destination의 역할을 번갈아 담당한다. 2개
의 버퍼는 각각 strBuffer[0]과 strBuffer[1]이다. 이 두 개의 버퍼를 이용하여
문자열을 생성하는 과정은 다음과 같다. 예로 든 문법을 다시 적어 보자.

1→23		2→2		3→24

4→25		5→65		6→7

7→8		8→9[3]		9→9

  strBuffer[0]를 primary 버퍼라 하고, strBuffer[1]을 secondary 버퍼라 하자.

  먼저 알고리즘은 primary 버퍼에 첫 번째 규칙 1→23을 씀으로서 시작한다.
이것을 담당하는 코드는 CGrammar의 InitRule()이다. InitRule()은 내부적으
로 CopyRule()을 호출하는데, CopyRule()은 규칙(Rule)이 들어 있는
strGrammar[] 배열의 인덱스와 목적 버퍼의 인덱스(strBuffer[]의 인덱스)를
받아 지정한 규칙을 대상 버퍼에 쓰는 일을 담당한다. 소스는 아래와 같다.

void CGrammar::CopyRule(int indexRule,int destBuffer) {

    int i=iBuffer[destBuffer];

    strcpy(&strBuffer[destBuffer][i],&strGrammar[indexRule][3]);

    iBuffer[destBuffer]+=(strlen(strGrammar[indexRule])-3);

}//CGrammar::CopyRule

  각 버퍼의 현재 위치는 iBuffer[]에 유지된다. 즉 strBuffer[0]의 현재 규칙이
쓰여지는 위치는 iBuffer[0]에, strBuffer[1]인 경우는 iBuffer[1]에 유지된다.
먼저 iBuffer[]를 이용하여 destBuffer의 현재 위치를 구한다.

int i=iBuffer[destBuffer];

  그리고 문법의 규칙(화살표까지를 제외한 부분)을 목적 버퍼에 쓴다. 문법
의 규칙은 S->TU의 형태이므로, 버퍼에 TU가 쓰이기 위해서는
strGrammar[][3]부터 복사를 시작해야 한다. 그러므로, 코드를 아래와 같이 작
성한다.

strcpy(&strBuffer[destBuffer][i],&strGrammar[indexRule][3]);

  이제 iBuffer[]를 갱신한다. 역시 전체 규칙의 길이에 3을 뺀 길이를 고려해
야 한다.

iBuffer[destBuffer]+=(strlen(strGrammar[indexRule])-3);

  이 과정을 마친 후의 그림을 아래에 나타내었다.

             InitBuffer()는 primary 버퍼에 첫 번째 규칙을 쓴다.

  이제 primary 버퍼의 스트링을 차례대로 secondary 버퍼에 쓰기 시작한다.
버퍼의 역할이 바뀔 때마다 iBuffer[]의 값은 0으로 초기화된다. 아래 그림의
첫 번째 단계에서 보듯이 primary 버퍼의 심벌 2는 2→2가 적용되며, primary
버퍼의 심벌 3은 3→24가 적용된다. 그러기 위해서는 심벌 2와 3의 문법 규칙
을 strGrammar[]에서 찾아야 하는데, 이것을 GetIndex()가 담당한다.

  GetIndex()는 심벌을 인자로 받아 이 심벌로 시작하는 규칙을 찾는다. 그리
고 strGrammar[]배열의 인덱스를 리턴한다. 규칙이 없는 경우 -1을 리턴한다.
즉 3이 24로 대치되는 과정은 GetIndex('3')의 호출에 의해, strGrammar[]의
인덱스 2(strGrammar[2]="3->24")가 구해진다. 이 인덱스를 CopyRule()의 인
자로 넘겨주어 확장을 계속한다.

  "22265"까지 확장되는 과정을 아래 그림에 나타내었다. 매 단계마다
iBuffer[]는 0으로 초기화된다.

             "22265"까지의 확장 과정: strBuffer[]는 역할을 바꾸어 가며 확장을 계속한
          다.

  "2229[3]8765"까지의 확장 결과는 아래와 그림과 같다.

             "2229[3]8765"까지의 확장 과정: 아직 CopyCharacter()는 한 번도 사용되지
          않았다. RewriteRule()은 GetIndex()의 리턴 값에 따라 CopyCharacter()와
          CopyRule()을 호출하는데 아직 CopyRule()만 호출하였다. 즉 GetIndex()가
          아직 -1을 한 번도 리턴하지 않았다.

  이제 처음으로 RewriteRule()의 CopyCharacter()가 호출된다. 심벌 [와 ]
는 규칙(rule)이 존재하지 않으므로 단순히 복사하면 된다. 이것은 그림에서
점선(dotted line)으로 나타내었다.

             "2229[24]9[3]8765"의 확장: 규칙이 존재하지 않는 심벌은 CopyCharacter()
          를 호출하여 단순히 복사한다.

  확장 단계는 nStep에 유지되는데, nStep만큼의 확장을 담당하는 실제 멤버
함수는 GenString()이다. 이 함수는 내부에서 iBuffer[]를 초기화하고,
strBuffer[]를 번갈아 가며 스캔(scan)하면서 RewriteRule()을 호출하여, 스트
링을 생성한다. 0과 1을 토글하기 위해서

src^=1;

을 사용하고 있다(이것은 '2진수'에서 설명하였다).

 또한 짝수 번의 확장이 일어난 경우 최종 스트링이 strBuffer[1]에 들어 있으
므로, 이것을 strBuffer[0]에 복사하는 다음 코드가 추가되어 있다. 즉 최종 스
트링은 항상 strBuffer[0]에 유지된다.

    if (src==1)

        strcpy(strBuffer[0],strBuffer[1]);

  완전한 소스를 아래에 리스트하였다. main()에서는 L-system의 문법을 보
여주고, 1에서 14단계까지의 스트링을 차례대로 보여주고 있다.

#include <iostream.h>

#include <conio.h>

#include <string.h>

#include <stdarg.h>

////////////////////////////////////////////////////////////////////////
/

// class CGrammar

#define MAX_GRAMMAR_NUM         30

#define MAX_GRAMMAR_LEN         20

#define MAX_BUFFER_STRING       1000

class CGrammar {

    int nGrammar;//number of valid grammars

    char strGrammar[MAX_GRAMMAR_NUM][MAX_GRAMMAR_LEN];

    int nStep;

    char strBuffer[2][MAX_BUFFER_STRING];

    int  iBuffer[2];//index to strBuffer[]

      //0 for primary buffer

      //1 for secondary buffer

  private:

    void CopyCharacter(int scr,int iSrc,int dest);

    void CopyRule(int indexRule,int destBuffer);

    int GetIndex(char symbol);

    void InitRule();

    void RewriteRule(int srcBuffer,int srcIndex);

  public:

    CGrammar() { nGrammar=nStep=0; }

    void SetGrammar(int n,...);

    void PrintGrammar();

    void SetStep(int n) { nStep=n; }

    void IncStep() { ++nStep; }

    void GenString(int step=0);

    void PrintString();

};//class CGrammar

void CGrammar::CopyCharacter(int src,int iSrc,int dest) {

//When there is no rewriting rule copy character from source to
destination

    int t=iBuffer[dest];

    strBuffer[dest][t]=strBuffer[src][iSrc];

    ++iBuffer[dest];

    strBuffer[dest][t+1]=0;//set EOS mark

}//CGrammar::CopyCharacter

void CGrammar::CopyRule(int indexRule,int destBuffer) {

    int i=iBuffer[destBuffer];

    strcpy(&strBuffer[destBuffer][i],&strGrammar[indexRule][3]);

      //                                                  ^skip left
symbols

    iBuffer[destBuffer]+=(strlen(strGrammar[indexRule])-3);

      //                                                ^skip left
symbols

}//CGrammar::CopyRule

int CGrammar::GetIndex(char symbol) {

    int i;

    for (i=0;i<nGrammar;++i) {

        if (strGrammar[i][0]==symbol)

            return i;

    }//for

    return -1;

}//CGrammar::GetIndex

void CGrammar::InitRule() {

    CopyRule(0,0);

      //copy first rule(which contains start symbol) to primary buffer

}//CGrammar::InitRule

void CGrammar::RewriteRule(int srcBuffer,int srcIndex) {

    int i=GetIndex(strBuffer[srcBuffer][srcIndex]);

    int destBuffer=srcBuffer^1;

    if (i==-1) {//when there is no rewriting rule

        CopyCharacter(srcBuffer,srcIndex,destBuffer);

    } else

        CopyRule(i,destBuffer);

      //                ^make it dest. buffer

}//CGrammar::RewriteRule

void CGrammar::SetGrammar(int n, ...) {

    va_list ap;

    char* arg;

    int i;

    nGrammar=n;

    va_start(ap,n);

    for (i=0;i<nGrammar;++i) {

        arg=va_arg(ap,char*);

        strcpy(strGrammar[i],arg);

    }//for

    va_end(ap);

}//CGrammar::SetGrammar

void CGrammar::PrintGrammar() {

    int i;

    for (i=0;i<nGrammar;++i)

        cout << strGrammar[i] << endl;

}//CGrammar::PrintGrammar

void CGrammar::GenString(int step) {

    if (step!=0) nStep=step;

    if (nStep==0) return;

    int i,src,iSrc;

    iBuffer[0]=iBuffer[1]=0;//initialize strBuffer[] pointer

    InitRule();//rewrite first rule to strBuffer[0](primary buffer)

    src=0;//set source buffer index to primary buffer

    for (i=1;i<nStep;++i) {

        iSrc=0;

        iBuffer[0]=iBuffer[1]=0;//initialize strBuffer[] pointer

        while (strBuffer[src][iSrc]!=0) {

            RewriteRule(src,iSrc);

            ++iSrc;

        }//while

        src^=1;//toggle source buffer index

    }//for

    if (src==1)

        strcpy(strBuffer[0],strBuffer[1]);

          //generated string is maintained in strBuffer[0](primary
buffer)

}//CGrammar::GenString

void CGrammar::PrintString() {

    cout << strBuffer[0] << endl;

      //              ^print primary buffer

}//CGrammar::PrintString

void main() {

    CGrammar g;

    g.SetGrammar(9,"1->23",

                   "2->2",

                   "3->24",

                   "4->25",

                   "5->65",

                   "6->7",

                   "7->8",

                   "8->9[3]",

                   "9->9");

    g.PrintGrammar();

    g.GenString(14);

    g.PrintString();

}//main

  실행 결과는 아래와 같다.

1->23

2->2

3->24

4->25

5->65

6->7

7->8

8->9[3]

9->9

23

224

2225

22265

222765

2228765

2229[3]8765

2229[24]9[3]8765

2229[225]9[24]9[3]8765

2229[2265]9[225]9[24]9[3]8765

2229[22765]9[2265]9[225]9[24]9[3]8765

2229[228765]9[22765]9[2265]9[225]9[24]9[3]8765

2229[229[3]8765]9[228765]9[22765]9[2265]9[225]9[24]9[3]8765

2229[229[24]9[3]8765]9[229[3]8765]9[228765]9[22765]9[2265]9[225]9[24]9[3
]8765

   위의 예에서 사용한 L-system의 문법은 반드시 S->형태로 시작하면
서 중간에 공백(space)이 없어야 한다. 실제로 문법을 파일에서 읽는다면, 공
백과 화살표를 전처리(preprocess)하는 루틴이 추가되어야 한다.

단계 4(step 4)

  이제 나무를 구성할 스트링까지 완성되었으므로, 스트링을 파싱(parsing)해
서 나무를 그려주는 부분만 남았다. 이 작업은 이 장의 처음에서 예로 들었던
것을 구현해 주면 된다.

  먼저 나무를 그리는 현재 위치를 거북이(turtle)■라는 자료 구조로 표현한

             이것의 이름은 고전적인 이유에서 출발한다. PC의 초창기 시절 그래픽
          라이브러리는 유일하게 거북이 그래픽(turtle graphic)만을 지원했다. 그래
          픽 함수는 거북이(turtle)라고 불리는 특정한 위치에서 반드시 시작해야 했
          다. 지금의 그래픽 함수처럼 임의의 위치에 타원을 그리는 작업은 거북이
          그래픽에서 지원하지 않았다. L-system의 경우 나무 가지의 현재위치를 항
          상 알고 있어야 하므로, 이것을 거북이라고 표현한다.

다음, 이것을 저장하는 스택을 만들어야 한다. 거북이는 (현재 위치, 현재 방
향, 현재 굵기, 현재 색)의 4쌍으로 표현한다.

  거북이는 다음과 같은 구조체로 표현할 수 있다.

struct CTurtle {

    int x,y,        //current position

        dir,        //current direction

        thickness,  //current line thickness

        color;      //current line color

};//class CTurtle

  '스택'에서 다루었던 템플릿 스택을 기억하는가? CTurtle 구조체를 저장하
기 위해서

CStack<CTurtle> stack(100);

을 사용할 수 있다. 위 문장은 CTurtle을 100개 저장할 수 있는 스택 객체를
생성한다(CStack의 소스는 아래에 리스트하였다).

  스트링을 파싱(parsing)하는 부분을 구현하기 위해 다시 아래의 스트링을
고려해 보자. 이것은 단계 14 스트링의 일부이다.

2229[229[24]9[3]8765]9[229[3]8765]9[228765]9...

    ① 왼쪽           ② 오른쪽    ③ 왼쪽  ...

  위의 스트링에서 밑줄 친 3 부분은 큰 가지(large branch)를 나타낸다. 여는
브래킷([)과 닫는 브래킷(]) 쌍이 맞아 떨어지는 부분을 묶은 것이다. 밑줄이
그어지지 않은 부분은 나무의 몸통을 형성한다. 만약 ①번 가지가 왼쪽으로
꺽였다면, ②번 가지는 오른쪽으로 꺽여야 한다. ③번은 다시 왼쪽으로 꺽이는
가지를 형성한다.

  이제 ①번 스트링만 고려해 보자.

...[229[24]9[3]8765]...

       ④   ⑤

  ④번과 ⑤번은 ①번 가지의 부분 가지를(sub branch) 이루는데, ①번이 왼
쪽으로 꺽였으므로, ④번과 ⑤번은 각각 왼쪽, 오른쪽으로 꺽이는 가지를 형성
한다. 즉 부분 가지의 첫 번째 꺽이는 방향은 큰 가지의 방향과 같다.

  이제 ②번 스트링을 고려해보자.

...[229[3]8765]...

       ⑥ 오른쪽

  ②번 가지는 오른쪽으로 꺽였으므로, ⑥번 부분 가지는 오른쪽으로 꺽이는
부분 가지를 형성한다.

  이처럼 나무를 그리는 과정을 살펴보면 두 가지 상태가 있음을 알 수 있다.

    0: 몸통을 그리는 상태

    1: 가지를 그리는 상태

2229[229[24]9[3]8765]9[229[3]8765]9[228765]9...

  위의 스트링에서 밑줄이 그어진 부분은 1번 상태에 해당하고, 밑줄이 없는
부분은 0번 상태에 해당한다. 그리고 각각의 상태에 현재 가지의 진행 방향
왼쪽, 오른쪽이 유지되어야 함을 알 수 있다.

  상태는 state에 유지하며, 0번 상태에서 가지의 진행 방향은 dir[0]에, 1번
상태에서의 가지의 진행 방향은 dir[1]에 유지하기로 하자. 이것은 다음과 같
이 초기화 할 수 있다(S_LEFT는 왼쪽, S_RIGHT는 오른쪽을 나타낸다).

    int dir[2]={S_LEFT,S_LEFT},

        state=0;//0: stack empty state

                //1: stack isn't empty state

  또한 왼쪽과 오른쪽으로 꺽이는 각도를 step[0]과 step[1]에 유지하기로 하
자. 이것은 다음과 같이 초기화 할 수 있다.

step[2]={-45,45};

  그러므로 진행 방향을 왼쪽으로 꺽기 위해서는,

turtle.dir+=step[0];

오른쪽으로 꺽기 위해서는,

turtle.dir+=step[1];

처럼 사용할 수 있다.

  아래에 완성된 오토마타를 표시하였다. 0번 상태에서 1번 상태로 처음 진입
하는 순간 dir[1]의 값은 dir[0]으로 초기화되어야 한다. 거북이의 위치를 저장
하기 위해 1개의 스택(Stack_turtle: 프로그램에서는 stack)을 가지의 진행 방
향을 저장하기 위해 또 1개의 스택(Stack_dir: 프로그램에서는 sstack)을 사용
하고 있다.

  Stack_turtle에서 거북이를 팝(pop)했을 때 스택의 크기가 0이라면, 1번 상
태에서 0번 상태로 진입한다. 물론 그 때 dir[0]의 값은 토글되어야 한다.

             Lindenmayer system: 스트링 해석을 위한 오토마타. #은 입력 심벌이 숫자
          임을 나타낸다. 거북을 저장하기 위해, 각 상태의 방향을 저장하기 위해 2
          개의 스택을 사용한다. 화살표에 붙은 설명은 '입력 / 동작'을 나타낸다.

  위 오토마타를 구현한 DrawTree()함수는 아래와 같다. 그림과 소스를 비교
해가면서 꼼꼼히 따라가기 바란다.

void CLindenmayer::DrawTree(int x,int y) {

    CStack<CTurtle>* stack;

    CStack<int>* sstack;

    int i=0;

    char symbol;

    int dir[2]={S_LEFT,S_LEFT},

        state=0;//0: stack empty state

                //1: stack isn't empty state

    SetColor(WHITE,3);

    SetTurtle(x,y,90,1,WHITE);

    stack=new CStack<CTurtle>(100);

    sstack=new CStack<int>(100);

    while ((symbol=grammar.strBuffer[0][i])!=0) {

        switch (symbol) {

        case '[':

            if (state==0) {

                stack->Push(turtle);

                turtle.dir+=step[dir[0]];

                dir[1]=dir[0];

                state=1;

            } else {

                stack->Push(turtle);

                sstack->Push(dir[1]);

                turtle.dir+=step[dir[1]];

            }//if.. else..

            break;

        case ']'://always state=1

            stack->Pop(turtle);

            sstack->Pop(dir[1]);

            dir[1]^=1;

            if (stack->Size()==0) {//When stack empty

                state=0;

                dir[0]^=1;

            }//if

            break;

        default:

            MoveTurtle();

            break;

        }//switch

        ++i;

    }//while

    delete stack;

    delete sstack;

}////CLindenmayer::DrawTree

  아래 그림은 단계 1에서 단계 25까지를 차례대로 그린 그림이다.

             Lindenmayer system: 단계 1에서 단계 25의 그림

  처음에 예로 들었던 단계 14의 그림은 아래와 같이 그려진다.

             Lindenmayer system: 단계 14의 그림

  단계 18의 그림은 아래와 같다.

             Lindenmayer system: 단계 18의 그림

  프로그램의 전체 소스를 아래에 리스트하였다. 프로그램은 화면의 중앙에
단계 18의 트리를 그린다. 프로그램은 모두 3개의 .CPP파일과 3개의 .H파일로
이루어져 있다. 소스가 1개 이상이므로, 프로젝트를 만들어야 한다. 프로젝트
를 만드는 법은 '프로젝트 만들기'에 설명되어 있다.

  CGraphic.h

#ifndef _CGraphicDefined_

#define _CGraphicDefined_

inline double ToRadian(int degree);

class CGraphic {

    int xCurrent,yCurrent,dirCurrent,lenCurrent;

    int xNew,yNew;

    int nThickness,nColor;

      //nThickness: NORM_WIDTH(1) or THICK_WIDTH(3) by BGI graphic
library

      //nColor: from BLACK(0) to WHITE(15) by BGI graphic library

  public:

    CGraphic() { xCurrent=yCurrent=dirCurrent=lenCurrent=0; }

    static void PolarLine(int x,int y,int degree,int length,int&
xEnd,int& yEnd);

    void SetPosition(int x,int y,int dir=-360,int len=-360);

    void SetDir(int dir) { dirCurrent=dir; }

    void SetLen(int len) { lenCurrent=len; }

    void SetThickness(int t) { nThickness=t; }

    void SetColor(int c,int t=0);

    void DrawStraightLine();

    void GetNewXY(int& x,int& y) { x=xNew; y=yNew; }

};//class CGraphic

#endif

  CGraphic.cpp

#include <conio.h>

#include <math.h>

#include <graphics.h>

#include "CGraphic.h"

////////////////////////////////////////////////////////////////////////
///

// Global functions

inline double ToRadian(int degree) {

    return (double)degree*M_PI/180.0;

      //                  ^defined in math.h

}//ToRadian

////////////////////////////////////////////////////////////////////////
///

// Member functions

void CGraphic::PolarLine(int x,int y,int degree,int length,

                        int& xEnd,int& yEnd) {

    int h,w;//height,width

    w=(int)(cos(ToRadian(degree))*(double)length);

    h=(int)(sin(ToRadian(degree))*(double)length);

    xEnd=x+w; yEnd=y-h;

    ::line(x,y,xEnd,yEnd);

}//CGraphic::PolarLine

void CGraphic::SetPosition(int x,int y,int dir,int len) {

    xCurrent=x;

    yCurrent=y;

    if (dir!=-360) dirCurrent=dir;

    if (len!=-360) lenCurrent=len;

}//CGraphic::SetTurtle

void CGraphic::SetColor(int c,int t) {

    nColor=c;

    if (t!=0) nThickness=t;

}//CGraphic::SetColor

void CGraphic::DrawStraightLine() {

    ::setlinestyle(SOLID_LINE,0,nThickness);

      //           ^defined in graphics.h

    ::setcolor(nColor);

    PolarLine(xCurrent,yCurrent,dirCurrent,lenCurrent,xNew,yNew);

}//CGraphic::DrawStraightLine

  CGrammar.h

#ifndef _CGrammarDefined_

#define _CGrammarDefined_

#define MAX_GRAMMAR_NUM         30

#define MAX_GRAMMAR_LEN         20

#define MAX_BUFFER_STRING       1000

class CGrammar {

    int nGrammar;//number of valid grammars

    char strGrammar[MAX_GRAMMAR_NUM][MAX_GRAMMAR_LEN];

    int nStep;

  public:

    char strBuffer[2][MAX_BUFFER_STRING];

    int  iBuffer[2];//index to strBuffer[]

      //0 for primary buffer

      //1 for secondary buffer

  private:

    void CopyCharacter(int scr,int iSrc,int dest);

    void CopyRule(int indexRule,int destBuffer);

    int GetIndex(char symbol);

    void InitRule();

    void RewriteRule(int srcBuffer,int srcIndex);

  public:

    CGrammar() { nGrammar=nStep=0; }

    void SetGrammar(int n,...);

    void PrintGrammar();

    void SetStep(int n) { nStep=n; }

    void IncStep() { ++nStep; }

    void GenString(int step=0);

    void PrintString();

};//class CGrammar

#endif

  CGrammar.cpp

#include <iostream.h>

#include <conio.h>

#include <string.h>

#include <stdarg.h>

#include "CGrammar.h"

void CGrammar::CopyCharacter(int src,int iSrc,int dest) {

//When there is no rewriting rule copy character from source to
destination

    int t=iBuffer[dest];

    strBuffer[dest][t]=strBuffer[src][iSrc];

    ++iBuffer[dest];

    strBuffer[dest][t+1]=0;//set EOS mark

}//CGrammar::CopyCharacter

void CGrammar::CopyRule(int indexRule,int destBuffer) {

    int i=iBuffer[destBuffer];

    strcpy(&strBuffer[destBuffer][i],&strGrammar[indexRule][3]);

      //                                                  ^skip left
symbols

    iBuffer[destBuffer]+=(strlen(strGrammar[indexRule])-3);

      //                                                ^skip left
symbols

}//CGrammar::CopyRule

int CGrammar::GetIndex(char symbol) {

    int i;

    for (i=0;i<nGrammar;++i) {

        if (strGrammar[i][0]==symbol)

            return i;

    }//for

    return -1;

}//CGrammar::GetIndex

void CGrammar::InitRule() {

    CopyRule(0,0);

      //copy first rule(which contains start symbol) to primary buffer

}//CGrammar::InitRule

void CGrammar::RewriteRule(int srcBuffer,int srcIndex) {

    int i=GetIndex(strBuffer[srcBuffer][srcIndex]);

    int destBuffer=srcBuffer^1;

    if (i==-1) {//when there is no rewriting rule

        CopyCharacter(srcBuffer,srcIndex,destBuffer);

    } else

        CopyRule(i,destBuffer);

      //                ^make it dest. buffer

}//CGrammar::RewriteRule

void CGrammar::SetGrammar(int n, ...) {

    va_list ap;

    char* arg;

    int i;

    nGrammar=n;

    va_start(ap,n);

    for (i=0;i<nGrammar;++i) {

        arg=va_arg(ap,char*);

        strcpy(strGrammar[i],arg);

    }//for

    va_end(ap);

}//CGrammar::SetGrammar

void CGrammar::PrintGrammar() {

    int i;

    for (i=0;i<nGrammar;++i)

        cout << strGrammar[i] << endl;

}//CGrammar::PrintGrammar

void CGrammar::GenString(int step) {

    if (step!=0) nStep=step;

    if (nStep==0) return;

    int i,src,iSrc;

    iBuffer[0]=iBuffer[1]=0;//initialize strBuffer[] pointer

    InitRule();//rewrite first rule to strBuffer[0](primary buffer)

    src=0;//set source buffer index to primary buffer

    for (i=1;i<nStep;++i) {

        iSrc=0;

        iBuffer[0]=iBuffer[1]=0;//initialize strBuffer[] pointer

        while (strBuffer[src][iSrc]!=0) {

            RewriteRule(src,iSrc);

            ++iSrc;

        }//while

        src^=1;//toggle source buffer index

    }//for

    if (src==1)

        strcpy(strBuffer[0],strBuffer[1]);

          //generated string is maintained in strBuffer[0](primary
buffer)

}//CGrammar::GenString

void CGrammar::PrintString() {

    cout << strBuffer[0] << endl;

      //              ^print primary buffer

}//CGrammar::PrintString

  CStack.h

#ifndef _CStackDefined_

#define _CStackDefined_

template<class T> class CStack {

    T* base;

    T* sp;

    int size;

  public:

    CStack(int s) { base=sp=new T[size=s]; }

    ~CStack() { delete[] base; }

    void Push(T a) { *sp++=a; }

    int Pop(T& a) {

        if (sp==base)

            return 0;

        else {

            a=*--sp; return 1;

        }//if.. else..

    }//Pop

    int Size() { return (int)(sp-base); }

};//class CStack

#endif//_CStackDefined_

  CLinden.cpp

#include <iostream.h>

#include <graphics.h>

#include <stdarg.h>

#include <conio.h>

#include <math.h>

#include <string.h>

#include "CGraphic.h"

#include "CGrammar.h"

#include "CStack.h"

struct CTurtle {

    int x,y,        //current position

        dir,        //current direction

        thickness,  //current line thickness

        color;      //current line color

};//class CTurtle

#define TREE_UNIT   15

class CLindenmayer {

    CTurtle turtle;

    int unit;

  public:

    enum {S_LEFT=0,S_RIGHT=1};

    static int step[2];

    CGrammar grammar;

    CGraphic graphic;

  private:

    void SetTurtle(int x,int y,int dir=-360,int thick=0,int color=-1);

    void MoveTurtle();

  public:

    CLindenmayer() { unit=TREE_UNIT; }//drawing unit

    void SetColor(int c,int t=0,int u=0);

    void DrawTree(int x=640/2,int y=479);

};//class CLindenmayer

int CLindenmayer::step[2]={45,-45};

void CLindenmayer::SetTurtle(int x,int y,int dir,int thick,int color) {

    turtle.x=x;

    turtle.y=y;

    if (dir!=-360) turtle.dir=dir;

    if (thick!=0) turtle.thickness=thick;

    if (color!=-1) turtle.color=color;

}//CLindenmayer::SetTurtle

void CLindenmayer::MoveTurtle() {

    graphic.SetPosition(turtle.x,turtle.y,turtle.dir,unit);

    graphic.DrawStraightLine();

    graphic.GetNewXY(turtle.x,turtle.y);

}//CLindenmayer::MoveTurtle

void CLindenmayer::SetColor(int c,int t,int u) {

    graphic.SetColor(c);

    if (t!=0) graphic.SetThickness(t);

    if (u!=0) unit=u;

}//CLindenmayer::SetColor

void CLindenmayer::DrawTree(int x,int y) {

    CStack<CTurtle>* stack;

    CStack<int>* sstack;

    int i=0;

    char symbol;

    int dir[2]={S_LEFT,S_LEFT},

        state=0;//0: stack empty state

                //1: stack isn't empty state

    SetColor(WHITE,3);

    SetTurtle(x,y,90,1,WHITE);

    stack=new CStack<CTurtle>(100);

    sstack=new CStack<int>(100);

    while ((symbol=grammar.strBuffer[0][i])!=0) {

        switch (symbol) {

        case '[':

            if (state==0) {

                stack->Push(turtle);

                turtle.dir+=step[dir[0]];

                dir[1]=dir[0];

                state=1;

            } else {

                stack->Push(turtle);

                sstack->Push(dir[1]);

                turtle.dir+=step[dir[1]];

            }//if.. else..

            break;

        case ']'://always state=1

            stack->Pop(turtle);

            sstack->Pop(dir[1]);

            dir[1]^=1;

            if (stack->Size()==0) {//When stack empty

                state=0;

                dir[0]^=1;

            }//if

            break;

        default:

            MoveTurtle();

            break;

        }//switch

        ++i;

    }//while

    delete stack;

    delete sstack;

}////CLindenmayer::DrawTree

CLindenmayer tree;//global object

void main() {

    int gd=0,gm;

    ::initgraph(&gd,&gm,"f:\\bc45\\bgi");

    tree.grammar.SetGrammar(9,"1->23",

                               "2->2",

                               "3->24",

                               "4->25",

                               "5->65",

                               "6->7",

                               "7->8",

                               "8->9[3]",

                               "9->9");

    tree.grammar.GenString(18);

    tree.DrawTree();

    getch();

    ::closegraph();

}//main

  이 장은 스트링의 다양한 해석을 다루지 않았다. 심벌을 다양화하면 가지의
굵기, 가지의 꺽이는 각도, 가지가 끝나는 곳에서의 꽃 등도 나타낼 수 있다.
이 책의 CD-ROM 부록에 있는 3차원 L-system을 참고하기 바란다.

  실습문제

1. DirectX를 이용하여 3차원 L-system을 작성하라.

2. OpenGL을 이용하여 3차원 L-system을 작성하라.

3.


11. XOR 애니메이션

  eXclusive Or(XOR) 연산은 그 특징 때문에 간단한 애니메이션에 여전히
많이 사용된다. 아래의 비트 스트링 01110101에 취한 XOR 연산은 XOR의
특징을 설명하고 있다.

<그림 ?> XOR 연산의 특징

  그래픽 화면의 어떤 배경에 그리고자 하는 그림을 배경과 XOR 연산으
로 그린다. 이 그림을 지우고자 하면 원래의 그림 위치에 다시 XOR로 그
려주기만 하면 배경이 회복된다. 하지만 XOR로 그리면 배경과의 XOR 연
산 때문에 전경 이미지의 색이 그대로 출력되지 않는다. 그러므로 이러한
응용은 그리고자 하는 이미지의 색이 별로 상관없는 간단한 애니메이션에
많이 사용된다.

  일반적으로 대부분의 그래픽 라이브러리는 그리기 함수가 배경과 어떠한
연산으로 그릴지를 정하는 함수가 마련되어 있다. 예를 들면 BGI에서는

setwritemode(XOR_PUT);

이라고 하면된다. 만약 배경이 15번 색(WHITE)인데 2번 색(GREEN)으로
선을 그리면 선은 무슨색으로 그려질까? 표준 VGA는 4비트를 사용하여
색을 표현하므로

1111 ^ 0010 = 1101

이 되어 13번 색(LIGHTMAGENTA)으로 그려지게 된다.

  그러면 먼저 화면을 돌아다니는 간단한 직선을 만들어 보기로 하자. 우
리가 만들 프로그램은 먼저 화면을 돌아다니는 점(Point)을 설계함으로써
시작한다.

<그림 ?> 점이 튕기는 원리

점은 대각선 방향으로 움직이다가 화면의 가장자리에 닿으면 방향을 바꾸
어 튕겨 나간다. 화면의 가장자리에 닿는 것은 다음의 if문으로 구현하면
된다.

if (x==0 || x==639) y축에 닿음

if (y==0 || y==479) x축에 닿음

  튕겨 나가는 것은 어떻게 구현할까? 점의 진행 방향을 각각 dx, dy에 유
지하고 있다가 축에 닿으면 그 축에 대칭 방향으로 점의 진행 방향을 바꾸
면 된다. 예를 들어 <그림 ?> 처럼 dx=1, dy=1로 진행하다가 x-축에 닿은
경우는 dy값의 진행 방향만 바꾸어 주면 점이 튕기게 되는 것이다. 이를
구현한 클래스 CPoint를 아래에 리스트하였다.

class CPoint {

    int x,y,dx,dy;

  public:

    CPoint(int tx,int ty) {

        x=tx; y=ty;

        dx=dy=1;

    }//CPoint

    int GetX() { return x; }

    int GetY() { return y; }

    void Move();

};//class CPoint

void CPoint::Move() {

    if (x==0 || x==639) dx=-dx;//meet y-axis

    if (y==0 || y==479) dy=-dy;//meet x-axis

    x+=dx; y+=dy;

}//CPoint::Move

  CPoint 클래스가 만들어지면 선이 움직이도록 하는 CLine 클래스를 만
드는 것은 무척 쉽다. CLine 클래스는 CPoint 객체를 두개 가지며 이 2개
의 점들 사이에 직선을 그어주기만 하면된다. 물론 한 개의 직선이 움직이
는 것처럼 보이기 위해서는

  (1) 현재 (x,y)에 선을 그린다.

  (2) 새로운 (x′,y′) 위치를 계산한다.

  (3) (x,y)에 그려져 있는 원래 선을 지운다.

  (4) 새로운 (x′,y′) 위치에 선을 그린다. 단계 (3)으로 간다.

처럼 반복해야 한다. 하지만 XOR 그리기 모드에서는 단계 (3)에서 그려져
있는 원래 선을 지울 때, 단지 한 번 더 그려주기만 하면 되는 것이다.

  완성된 클래스와 프로그램의 실행 모습을 아래에 리스트하였다.

<그림 ?> 실행중인 XOR 애니메이션

#include <stdio.h>

#include <conio.h>

#include <dos.h>

#include <graphics.h>

class CPoint {

    int x,y,dx,dy;

  public:

    CPoint(int tx,int ty) {

        x=tx; y=ty;

        dx=dy=1;

    }//CPoint

    int GetX() { return x; }

    int GetY() { return y; }

    void Move();

};//class CPoint

void CPoint::Move() {

    if (x==0 || x==639) dx=-dx;//meet y-axis

    if (y==0 || y==479) dy=-dy;//meet x-axis

    x+=dx; y+=dy;

}//CPoint::Move

class CLine {

    CPoint *p[2];

  private:

    void DrawALine() {

        line(p[0]->GetX(),p[0]->GetY(),

             p[1]->GetX(),p[1]->GetY());

    }//DrawALine

  public:

    CLine();

    ~CLine();

    void Draw();

};//class CLine

CLine::CLine() {

    p[0]=new CPoint(100,100);

    p[1]=new CPoint(500,200);

    DrawALine();//draw a line

}//CLine::CLine

CLine::~CLine() {

    delete p[0];

    delete p[1];

}//CLine::~CLine

void CLine::Draw() {

    DrawALine();//erase previous line

    p[0]->Move();

    p[1]->Move();

    DrawALine();//draw a line

}//CLine::Draw

void main() {

    int gd=0,gm;

    initgraph(&gd,&gm,"..\\..\\bgi");

    setwritemode(XOR_PUT);//set eXclusive Or write mode

    CLine line;

    while (!kbhit()) {

        line.Draw();

        delay(10);

    }//while

    getch();

    closegraph();

}//main

개미 만들기

  페인트 프로그램에서 현재 선택된 객체를 나타내기 위해 테두리의 까만
점들이 움직이는 것을 본 적이 있을 것이다. 이 때 움직이는 까만 점들을
개미(ant)라고 한다. 개미를 구현해 보자.

<그림 ?> 개미

  개미는 서로 다른 종류의 선들을 연속해서 그려주면 쉽게 구현된다. 일
반적으로 그래픽 라이브러리는 선의 굵기와 모양을 정해주는 함수를 지
원한다. 그리고 이미 그려진 앞의 선을 지울 때 XOR 연산을 하면된다.

1을 검은색, 0을 흰색이라 하면,

1100 1100 1100 1100(0xcccc)

0110 0110 0110 0110(0x6666)

0011 0011 0011 0011(0x3333)

1001 1001 1001 1001(0x9999)

를 반복해서 그려주면 되는 것이다.

  완성된 소스를 아래에 리스트하였다.

#include <stdio.h>

#include <conio.h>

#include <dos.h>

#include <graphics.h>

int shape[]={0xcccc,0x6666,0x3333,0x9999};

void main() {

    int gd=0,gm;

    int i=0;

    initgraph(&gd,&gm,"..\\..\\bgi");

    setwritemode(XOR_PUT);

    setlinestyle(USERBIT_LINE,shape[i],1);

    rectangle(100,100,200,200);//draw

    while (!kbhit()) {

        rectangle(100,100,200,200);//erase

        setlinestyle(USERBIT_LINE,shape[i],1);

        i=(i+1)%4;

        rectangle(100,100,200,200);//draw

        delay(100);

    }//while

    getch();

    closegraph();

}//main

실습문제

1. 윈도우의 제목 막대(Title Bar)나 스크롤 박스를 끌어다 놓기 할 때 외
관을 나타내는 희미한 테두리가 나타나서 현재의 위치를 알려준다. 이 때
나타나는 희미한 상자를 유령 상자(Ghost Box)라고 한다. 이 유령 상자
를 흉내내는 프로그램을 작성하라.

<그림 ?> 유령 상자

2. 윈도우의 대표적인 화면 보호 프로그램인 뫼비우스의 띠를 구현하라.

3.


12. Escape 절차(Sequence)

12. Escape 절차
(Sequence)

  (衁)과 (遁) 두 대의 컴퓨터가 통신을 이용하여 숫자 데이터를 서로 교환한
다고 하자. 이러한 컴퓨터 통신에 사용되는 규약을 통신 규약 혹은 프로토콜
(Protocol)이라 한다.

<그림 ?> 통신 규약(Protocol)

  우리가 사용할 프로토콜은 숫자만 데이터로 사용하므로, a를 시작 바이트,
b를 끝 바이트로 사용하기로 하였다. 그러므로 123을 전송하려면,

a123b

를 차례대로 보내면 된다. 나타내기는 위처럼 나타냈지만, 실제 전송은

b 3 2 1 a

---------、

의 순으로 진행된다.

  그런데 아무런 문제없이 사용하던 이 프로토콜에 문제가 생겼다. 영문자도
데이터에 포함시켜야 하는 일이 발생한 것이다. 단순하게 위의 프로토콜을 사
용하면, 12a3을 데이터로 전송하는 경우,

a12a3b

를 전송하게 되어, a를 구분하는 방법이 모호해진 것이다. 그래서 다음과 같이
프로토콜을 확장하였다.

  (1) 시작 바이트와 끝 바이트는 각각 'ea', 'eb'로 나타낸다.

  (2) e가 데이터에 포함되는 경우는 'ee'를 전송한다.

만약, 전송해야할 데이터가, 12a3인 경우는

ea12a3eb

를 전송하면, e다음에 오는 문자를 확인함으로써 시작 바이트와 끝 바이트를
구분하는 것이 가능해 진다. 보내고자 하는 데이터가 12e3a인 경우는

ea12ee3aeb

를 전송하면 데이터와 시작, 끝 바이트를 구분하는 것이 가능해 진다. 즉 이
데이터를 전송 받는 측에서는 e다음에 오는 문자를 검사하여

  (1) e 다음에 a가 오면 시작 바이트

  (2) e 다음에 b가 오면 끝 바이트

  (3) e 다음에 e가 오면 데이터 e

로 간주하면 되는 것이다. 이러한 것은 사용자에 의해 데이터가 주어지면 e를
덧붙이는 일은 하드웨어 장치에 의해 자동으로 이루어졌는데, 이것을 Esc 절
차(Escape Sequence)라고 한다.

  C++에서 스트링은 이러한 Esc 절차를 지원한다. 어떠한 표현이 Esc 절차를
사용한다고 하면, 그 표현이 사용하는 Esc 문자가 무엇인지를 확인해야 하며,
Esc 다음에 사용하는 약속된 문자에는 무엇이 있는지 확인해야 한다. C++에
서는 '\'를 Esc 문자로 사용하며, 특별히 printf() 같은 몇 함수는 숫자를 나타
내기 위해 '%'도 사용한다. 그리고 Esc 다음에 올 수 있는 약속된 문자에는

n, a, b, t, v

등이 있다. 예를 들어 "\aa\\\nn"을 해석해 보면,

\a + a + \\ + \n + n

이므로,

"삑 소리나고, 'a' 출력, '\' 출력, 줄 바꾸고, 'n' 출력"

을 의미한다. 이러한 Esc 절차의 개념은 여러 곳에 다양하게 응용되어 사용되
므로 개념을 반드시 익혀두어 여러분의 응용에 사용할 수 있어야 한다.

프린터 제어 언어(Print Control Language: PCL)

 아래의 루틴은 VGA 표준 640×480, 16 컬러모드를 레이저 프린터로 출력하
는 함수이다. 이러한 프린터를 제어하는 언어를 PCL이라고 하는데, PCL의
여러 가지 기능은 Esc 절차로 제어한다. 예를 들어 해상도를 설정하는 Esc
절차는

Esc + * + t + 해상도 + R

이므로, 해상도를 100으로 할 경우, C++의 문자열로는

"\0x1B*t100R"

을 전송하면 되는 것이다.

int HPLaserPrint(int x1, int y1, int x2, int y2,

                 int DPI, int PORT_NUM, int LEFT_SKIP,

                 int VERTICAL_SKIP)

/* Hewlett Packard laser printing routine

   Maybe compatible for any laser printer

   Working for VGA standard only. So [x, y] = [ (0~639), (0~479)]

   DPI : 75,100,150,300

   PORT_NUM : 1,2,3,4

   LEFT_SKIP : some bytes

   VERTICAL_SKIP : some bytes

   N.B. 1xxx pixel will be printed i.e. a bit on plane 3 will be

   printed */

{

    unsigned char pattern[10];

    int i, j;

    if (PORT_NUM == 0) PORT_NUM=1; // LPT 1

    if (_bios_printer(_PRINTER_STATUS,0,PORT_NUM-1) & 0x8)

        return -1;//IO error

    if (_bios_printer(_PRINTER_STATUS,0,PORT_NUM-1) & 0x20)

        return -2;//Out of paper error

    if (!(_bios_printer(_PRINTER_STATUS,0,PORT_NUM-1) & 0x10))

        return -3;//not selected

    // fprintf(stdprn, "\33%d", 9); // clear side margins

    // fprintf(stdprn, "\33&a%dL", 0); // Left side margins

    // fprintf(stdprn,"\33*p%dx%dY",x,y); // Cursor positioning

    // fprintf(stdprn, "\33*b%dY",1); // Relative vertical pixel

                                      //movement

    fprintf(stdprn, "\33*t%dR", DPI);  // resolutions 75,100,150,300

    fprintf(stdprn, "\33*r1A");

      //Start graphics at 1:current cursor position, 0:left most

      //position

    for (i=y1;i<=y2;i++) {

        fprintf(stdprn,"\33*b%dW", (x2-x1+1)/8+LEFT_SKIP);

            //Transfer Graphics : number of bytes

        for (j=0; j < LEFT_SKIP; j++)

            biosprint(_PRINTER_WRITE, 0, PORT_NUM-1);

        for (j=x1/8; j < (x2-x1+1)/8; j++) {

            getimage(j*8, i, j*8+7, i, pattern);

            biosprint(_PRINTER_WRITE, pattern[4], PORT_NUM-1);//send

                                                            //a byte

        }//for                                ^Image head skip

    }//for

    for (i=0; i < VERTICAL_SKIP; i++) {

        fprintf(stdprn,"\33*b%dW", 1);//Transfer Graphics : number of

                                      //bytes

        biosprint(_PRINTER_WRITE, 0, PORT_NUM-1);

    }//for

    fprintf(stdprn,"\33*rbC");//End graphics

    return 0;

}//HPLaserPrint

  자세한 설명은 하지 않았지만, 위의 함수를 HP DeskJet등의 프린터에도 바
로 이용할 수 있다. 이제 우리가 직접 Esc 절차를 설계해 보자.

특정한 문자를 강조(Highlight)하기

  "&Hello"라고 표현한 경우, H를 특별히 강조하여 찍고, 나머지는 평범한 회
색을 출력하는 문자열 출력 함수를 만들려고 한다. 즉 &가 Esc 문자로 사용
된 것이다. 이것을 구현한 소스는 아래에 리스트 하였다.

typedef unsigned char byte;

char far *videoram = MK_FP(0xb800,0);

void _putchxy(int x,int y,byte c,byte fore,byte back,byte blink)

{

    byte attr;

    int i=(y-1)*160+(x-1)*2;

    attr=fore & 0x00ff;

    attr=attr | ((back << 4) & 0x70);

    attr=attr | ((blink != 0) << 7);

    *(videoram+i)=c;

    *(videoram+i+1)=attr;

}

int puthistr(int x,int y,char s[],byte fore,byte back,byte hi)

{

    int i=0,tx=x,ret=-1;

    while (i < strlen(s)) {

    if (s[i]=='&') {

        ++i;

        _putchxy(tx,y,s[i],hi,back,0);

        ret=toupper(s[i]);

    } else

        _putchxy(tx,y,s[i],fore,back,0);

    ++i;

    ++tx;

    }

    return ret;

}

  그렇다면 위의 소스를 이용하여 팝업 메뉴(Popup Menu)를 만들어 보자. 우
리가 설계할 메뉴는 Esc 절차를 이용하여 특정 문자를 강조할 뿐만 아니라,
그 문자를 접근키(Access Key)로 등록한다. 예를 들어 메뉴 항목이

File

처럼 출력이 되었다면, 메뉴가 활성화 되었을 때, 'F'를 누르면 그 메뉴 항목
이 선택된다. 물론 방향키를 이용하여 선택할 수도 있다. 소스를 아래에 리
스트하였다.

<그림 ?> 실행 화면

#include <stdio.h>

#include <conio.h>

#include <stdlib.h>

#include <string.h>

#include <dos.h>

#include <ctype.h>

#define KEY_HOME      71

#define KEY_UP        72

#define KEY_PGUP      73

#define KEY_LEFT      75

#define KEY_RIGHT     77

#define KEY_END       79

#define KEY_DOWN      80

#define KEY_PGDN      81

#define KEY_ENTER     13

#define KEY_ESC       27

#define MAX_ITEM      10

#define MAX_LEN       30

#define putchxy(x,y,c)     _putchxy(x,y,c,7,0,0)

#define putsxy(x,y,s)      _putsxy(x,y,s,7,0,0)

enum {FALSE,TRUE};

typedef unsigned char byte;

char far *videoram = MK_FP(0xb800,0);

char item[MAX_ITEM][MAX_LEN];

int  accesskey[MAX_ITEM];

char MFC=BLACK,

     MBC=LIGHTGRAY,

     MHC=RED,

     HFC=LIGHTGRAY,

     HBC=BLACK;

void _putchxy(int x,int y,byte c,byte fore,byte back,byte blink)

{

    byte attr;

    int i=(y-1)*160+(x-1)*2;

    attr=fore & 0x00ff;

    attr=attr | ((back << 4) & 0x70);

    attr=attr | ((blink != 0) << 7);

    *(videoram+i)=c;

    *(videoram+i+1)=attr;

}

void _putsxy(int x,int y,char s[],byte fore,int back,byte blink)

{

    int i;

    for (i=0;i<strlen(s);++i)

    _putchxy(x+i,y,s[i],fore,back,blink);

}

void clearbox(int x1,int y1,int x2,int y2,byte c)

{

    int i,j;

    for (i=x1;i<=x2;++i)

    for (j=y1;j<=y2;++j)

        _putchxy(i,j,' ',0,c,0);

}

void _box(int x1,int y1,int x2,int y2,byte fore,byte back)

{

    int i;

    for (i=x1;i<=x2;++i) {

    _putchxy(i,y1,'-',fore,back,0);//

    _putchxy(i,y2,'-',fore,back,0);

    }

    for (i=y1;i<=y2;++i) {

    _putchxy(x1,i,'|',fore,back,0);

    _putchxy(x2,i,'|',fore,back,0);

    }

    _putchxy(x1,y1,'+',fore,back,0);

    _putchxy(x2,y1,'+',fore,back,0);

    _putchxy(x1,y2,'+',fore,back,0);

    _putchxy(x2,y2,'+',fore,back,0);

}

void box(int x1,int y1,int x2,int y2,byte fore,byte back)

{

    clearbox(x1,y1,x2,y2,back);

    _box(x1,y1,x2,y2,fore,back);

}

int puthistr(int x,int y,char s[],byte fore,byte back,byte hi)

{

    int i=0,tx=x,ret=-1;

    while (i < strlen(s)) {

    if (s[i]=='&') {

        ++i;

        _putchxy(tx,y,s[i],hi,back,0);

        ret=toupper(s[i]);

    } else

        _putchxy(tx,y,s[i],fore,back,0);

    ++i;

    ++tx;

    }

    return ret;

}

int strlen2(char s[])

{

    int i=0,j=0;

    while (s[i] != '\0') {

    if (s[i] == '&') ++j;

    ++i;

    }

    return i-j;

}

int popup(int x,int y,int nitem,int base)

{

    int maxlen=0,i=0,c=0,ci=0,ret=-4;

    byte eKey=FALSE;

    char *buf;

    while (i<nitem) {

    if (strlen2(item[i]) > maxlen)

        maxlen=strlen2(item[i]);

    ++i;

    }

    buf=(char *)malloc( (maxlen+2)*(nitem+2)*2 );

      //save background

    gettext(x,y,x+maxlen+1,y+nitem+1,buf);

    box(x,y,x+maxlen+1,y+nitem+1,MFC,MBC);

    for (i=0;i<nitem;++i)

    accesskey[i]=puthistr(x+1,y+1+i,item[i],MFC,MBC,MHC);

    puthistr(x+1,y+1,item[0],HFC,HBC,HFC);

    while (1) {

    eKey=FALSE;

    c=getch();

    if (c==0) {

        eKey=TRUE;

        c=getch();

    }

    puthistr(x+1,y+1+ci,item[ci],MFC,MBC,MHC);

          //restore inverted item

    if (eKey==FALSE) {

        for (i=0;i<nitem;++i) {

        if (toupper(c)==accesskey[i]) {

            ret=base+i;

            break; /* exit for loop */

        }

        }

        switch (c) {

        case KEY_ENTER:

            ret=base+ci;

            break;

        case KEY_ESC:

            ret=-3;

            break;

        }

        if (ret != -4) break; /* exit while loop */

    } else { /* When extended key is pressed */

        switch (c) {

        case KEY_UP:

            --ci;

            if (ci < 0) ci=nitem-1;

            break;

        case KEY_DOWN:

            ++ci;

            if (ci>=nitem) ci=0;

            break;

        case KEY_HOME:

            ci=0;

            break;

        case KEY_END:

            ci=nitem-1;

            break;

        case KEY_LEFT:

            ret=-1;

            break;

        case KEY_RIGHT:

            ret=-2;

            break;

        }

        if (ret != -4) break; /* exit while loop */

    }

    puthistr(x+1,y+1+ci,item[ci],HFC,HBC,HFC); /* invert item */

    }

    puttext(x,y,x+maxlen+1,y+nitem+1,buf); /* restore background */

    free(buf);

    return ret;

}

void main(void)

{

    int i;

    strcpy(item[0]," &New     ");

    strcpy(item[1]," &Open    ");

    strcpy(item[2]," &Save    ");

    strcpy(item[3]," Save &as ");

    strcpy(item[4]," E&xit    ");

    i=popup(10,10,5,100);

    printf("%d\n",i);

}

  위의 소스는 잡다한 많은 부분을 구현하였으므로, Esc 절차만 제대로 이해
하였다면, 소스 모두를 이해할 필요는 없다.

실습문제

1. 위에서 문자열을 출력하는 함수 puthistr()은 "H&&ello" 처럼 연속된 & 가
사용된 것은 처리하지 않는다. Esc 절차의 정의에 맞게 동작하도록 puthistr()
함수를 수정하라.

2. 위에서 구현 한 팝업 메뉴는 메뉴 항목을 할당하기 위해 여러 줄의
strcpy() 문장을 사용하고 있다. 이 부분을 가변 인자(Variable Argument)를
사용하여, 항목을 할당 할 수 있도록 SetMenuItem()이란 함수를 추가하라.

3. popup()이 가속키도 지원하도록 Esc 절차를 수정하여 구현하라. 예를 들어,
"&Hello\t@X" 인 경우, 가속키로 Alt+X를 누르면 이 메뉴 항목이 선택되어야
한다. 즉 '\t'를 추가적인 Esc 문자로 사용하라.

4.


13. 스트링 거리(String Distance)

13. 스트링 거리(String
Distance)

  독자들 중 대부분은 이 장(chapter)의 주제에 흥미 없어 할 것이다. 이 장은
다음과 같은 사람들을 위한 장이다.

  (1) 전통적인 문자인식(character recognition) 방법을 익히려는 사람

  (2) 텍스트 에디터의 찾기에 '대충 찾기' 기능을 추가하려는 사람

  (3) 서로 다른 길이를 가지는 스트링(string, 문자열)의 다르기 척도(metric)
를 구하는 이 알고리즘이 자신의 응용에 이용될 것을 확신하는 사람

  스트링의 거리는 길이가 다른 2개의 스트링간의 거리, 즉 얼마만큼 틀린가
의 척도를 의미한다. 예를 들어 독자들이 찾고자 하는 스트링이

"hello"와 약 3정도 차이나면서, 길이가 9보다 작은 문자열

이라고 한다면, 이러한 문자열을 어떻게 찾을 것인가? 아마도 독자들이 찾고
자 하는 문자열에는

hellow, fellow, othello, welslo

가 포함되어 있을 지 모른다. 또한,

a, e, i, o, u를 포함하는 길이가 7보다 작은 문자열

을 검색을 원한다면, 여기서 제시하는 이 방법은 최선의 해결책이 될 것이다.

  독자들이 이미 느꼈을지도 모르지만, 미리 정해진 어떤 척도와 프로그램에
서 입력으로 얻은 값을 스트링으로 표현해서 비교한다면, 비슷한 것을 찾는
문제, 즉 인식(recognition) 문제에도 이용할 수 있다.

  그렇다면, 이 오래된 쓸모 없는 알고리즘을 형식적(formal)으로 정의해 보자
■.

             Graham A Stephen, "String Search", School of Electronics Engineering
          Science University College of North Wales, October 1992, email:
          graham@sees.bangor.ac.uk

  두 개의 스트링간의 거리는, 동적 프로그래밍(dynamic programming)을 이
용하면, 앞의 결과를 계속해서 평가하여 이용함으로써 최종적인 결과를 얻을
수 있게 된다. 이 과정을 자세히 기술해 보자.

  길이가 각각 i와 j인, 두 개의 스트링 x와 y의 접두 스트링(prefix)간의 거
리를 di,j로 나타내자. 즉,

di,j=d(x[1..i], y[1..j])■

             x[i..j]는 스트링 x에서, i부터 j까지의 부분 문자열(Substring)을 의미한다.

이다. 심벌(symbol) a를 심벌 b로 바꾸는데 드는 비용을 결정하는 함수를
w(a,b)로 나타내기로 하자. 즉 w(a,b)는 a≠b일 때 a를 b로 대치하는 비용이
며, w(a,ε■)는 a를 지우는 비용이며, w(ε,b)는 b를 삽입하는 비용이다. d가

            ε(epsilon)은 형식 언어 이론(the theory of formal language)에서 NULL 스
          트링을 나타내기 위해서 종종 사용된다. NULL 스트링과 빈 스트링(empty
          string)을 구분하라. C++에서 NULL 스트링은 0이지만, empty 스트링은 "" -
          반드시 0이 아니다 - 이다.

아래와 같이 정의되었을 때를 Levenshtein 거리(distance)라 한다.

    w(a,ε) = 1

    w(ε,b) = 1

    w(a, b) = 1 if a≠b

            = 0 otherwise

  계산에 의해, di,j의 값은 (m+1)×(n+1) 배열■에 기록한다. 그리고 다음 점화

             x의 길이를 m, y의 길이를 n이라 가정한다.

식(recurrence relation)을 이용해서 계산한다.

    di,j=min{di-1,j + w(xi,ε),  di,j-1 + w(ε,yi),  di-1,j-1 + w(xi,yi)}

       ④	①		②		③

  위의 관계는 다음과 같이 유도하였다. ① 만약 x[1,i-1]을 y[1,j]로 바꾸는 비
용이 알려져 있다면, x[1,i]를 y[1,j]로 바꾸는 것은 xi를 지우는 비용이 추가될
것이다. ② 비슷하게, x[1,i]를 y[1,j-1]로 바꾸는 비용이 알려져 있다면, x[1,i]
를 y[1,j]로 바꾸는 것은 yi를 삽입하는 비용이 추가될 것이다. 마지막으로, ③
만약 x[1,i-1]을 y[1,j-1]로 바꾸는 비용이 알려져 있다면, x[1,i]를 y[1,j]로 바
꾸는 것은 xi를 yi로 바꾸는 비용이 추가될 것이다. di,j는 x[1,i]를 y[1,j]로 바꾸
는 최소비용을 의미하므로, 바른 값의 선택을 위해서 위에서 언급한 3개의 연
산 중 ④ 최소값을 고르는 과정이 필요하다.

  di,j 계산을 수행하기 전에, 배열의 경계■를 설정하는 것이 필요하다. 배열

             이 부분의 자세한 사항은 'Board가 필요하면, Border를 사용하라'
          를 보라.

의 첫 번째 열(column)에 대해, di,0의 값은 x의 첫 번째 i개의 심벌들을 지우
는 비용의 합과 같다. 비슷하게, 첫 번째 줄(row)에 대해서도 d0,j의 값은 y의
첫 번째 j개의 심벌들을 삽입하는 비용의 합과 같다. 그러므로 아래 식을 유
도할 수 있다.

LPILE { d sub {0,0} = 0#

d sub {i,0} = SUM from { { k}=1} to i w(x sub k , ε) ~~~ for 1 ≤ i ≤
m#

d sub {0,j} = SUM from { { k}=1} to J w(ε, y sub k ) ~~~ for 1 ≤ j ≤
n #

}

  Levenshtein 거리의 경우, di,0=i이고 d0,j=j이다. 위에서 제시한 알고리즘을
아래 <알고리즘 1>에 제시하였다.

//initialize array borders

d0,0=0

for i=1 to m

    di,0=di-1,0+w(xi,ε)

for j=1 to n

    d0,j=d0,j-1+w(ε,yj)

//calculate di,j

for i=1 to m

    for j=1 to n

        di,j=min{di-1,j + w(xi,ε), di,j-1 + w(ε,yi), di-1,j-1 + w(xi,yi)}

<알고리즘 1> Wagner와 Ficher의 스트링 거리 구하기 계산

  두개의 스트링, preterit와 zeitgeist를 예로 들어 배열의 내용을 구하는
과정을 살펴보자. 이 예의 경우, 이 두개의 스트링간의 거리 d8,9는 6이다.

j

0

1

2

3

4

5

6

7

8

9

i

z

e

i

t

g

e

i

s

t

0

0

1

2

3

4

5

6

7

8

9

1

p

1

1

2

3

4

5

6

7

8

9

2

r

2

2

2

3

4

5

6

7

8

9

3

e

3

3

2

3

4

5

5

6

7

8

4

t

4

4

3

3

3

4

5

6

7

7

5

e

5

5

4

4

4

4

4

5

6

7

6

r

6

6

5

5

5

5

5

5

6

7

7

i

7

7

6

5

6

6

6

5

6

7

8

t

8

8

7

6

5

6

7

6

6

6

             스트링 거리를 구하면서 만들어진 2차원 배열

  위의 테이블은 0번 행과 0번 열이 0부터 문자열의 길이까지의 정수로 채워
진 다음, 왼쪽에서 오른쪽으로, 위에서 아래로 차례대로 구해진다.

  알고리즘의 처음 두 for문에 의해서 테이블은 다음과 같이 초기화된다.

j

0

1

2

3

4

5

6

7

8

9

i

z

e

i

t

g

e

i

s

t

0

0

1

2

3

4

5

6

7

8

9

1

p

1

2

r

2

3

e

3

4

t

4

5

e

5

6

r

6

7

i

7

8

t

8

             테이블의 엔트리를 구하는 과정: 0행과 0열을 문자열의 길이에 비례하도
          록 초기화한다.

  세 번째 중복된 for문에 의해서, 1행 1열에서 1행 9열까지의 값이 결정된다.

j

0

1

2

3

4

5

6

7

8

9

i

z

e

i

t

g

e

i

s

t

0

0

1

2

3

4

5

6

7

8

9

1

p

1

1

2

3

4

5

6

7

8

9

2

r

2

3

e

3

4

t

4

5

e

5

6

r

6

7

i

7

8

t

8

             1행을 모두 구한 테이블: 1행부터 계산한다. 같은 행에서는 왼쪽에서 오
          른쪽으로 계산된다. 1행의 계산이 모두 끝나면, 2행의 순으로 진행한다.

  1행 1열의 값을 구하는 방법은 아래 그림과 같다.

             배열의 엔트리를 구하는 법: 0행 1열의 값은 1이다. 이것은 원래의 거리 1
          에 새로운 문자를 추가할 것을 요구하므로, 1행 1열의 추천되는 거리는 2
          이다. 1행 0열인 경우도 같은 방식으로 계산하면, 추천되는 거리는 2이다.
          0행 0열의 거리에서 추천되는 거리는 0+w(p,z) - 'p'를 'z'로 바꾸는 비용은
          1이다 - 이므로 1이다. 그러므로 구하고자 하는 최종거리 min(2,2,1)은 1이
          므로, 1행 1열의 엔트리는 1로 결정된다.

  배열의 구하고자 하는 엔트리는 위에서 제시한 식에서 표현했듯이, 이미 구
해진 위쪽, 왼쪽, 왼쪽 대각선 위쪽의 값을 이용하여 구한다. 위쪽과 왼쪽인
경우는 원래 값에 항상 1이 더해진다. 왼쪽 대각선 위쪽의 경우는 원래 값에
0아니면 1이 더해진다. 이 경우는 p와 z가 다르므로, 1을 더해야 한다. 그러므
로, 최종 값은 2,2와 1중에서 최소값이므로 1이다.

  예를 들면, 4행 4열의 값이 3으로 결정된 이유는 t와 t의 거리가 0이기 때문
이다.

  이렇게 모든 값들을 차례로 구하여 만들어진 2차원 배열에서, m행 n열의
값이 - 이 경우는 6 - 두 스트링간의 거리이다.

  이것을 구현한 클래스 소스를 아래에 리스트하였다.

  CStringDistance는 2개의 문자열을 위해 pattern과 text를 데이터 멤버로 가
진다. 생성자는 이 두 문자열을 초기화하며, 알고리즘은 멤버 함수
Construct()로 구현하였다. 결과는 읽기 함수(reader function)인 Distance()를
호출하여 알 수 있다. 여러 번의 반복 수행이 가능토록 하기 위해, 별도의 멤
버 함수 SetString()을 가지는데, 파라미터가 1개인 경우는 pattern만을 설정
한다.

#include <stdio.h>

#include <conio.h>

#include <string.h>

typedef unsigned char byte;

class CStringDistance {

    byte d[100][100];

    char *pattern,*text;

    int lenPattern,lenText,distance;

  private:

    int Weight(int i,int j);

    int Min(int a,int b,int c);

    void Construct();

  public:

    CStringDistance(char *p,char *t);

    ~CStringDistance();

    int Distance() { return distance; }

    void SetString(char *p,char *t=NULL);

};//class CStringDistance

inline int CStringDistance::Weight(int i,int j) {

    return pattern[i]!=text[j];

}//Weight

inline int CStringDistance::Min(int a,int b,int c) {

    if (a<b)

        return a<c?a:c;

    else

        return b<c?b:c;

}//GetMin

void CStringDistance::Construct() {

    int i,j;

    d[0][0]=0;

    for (i=1;i<=lenPattern;++i)

        d[i][0]=i;

    for (i=1;i<=lenText;++i)

        d[0][i]=i;

    for (i=1;i<=lenPattern;++i)

        for (j=1;j<=lenText;++j)

            d[i][j] = Min(d[i-1][j]+1, d[i][j-1]+1,

                          d[i-1][j-1]+Weight(i-1,j-1));

    distance=d[lenPattern][lenText];

}//Construct

CStringDistance::CStringDistance(char *p,char *t) {

    pattern=new char[strlen(p)+1];

    text=new char[strlen(t)+1];

    strcpy(pattern,p); strcpy(text,t);

    lenPattern=strlen(pattern); lenText=strlen(text);

    Construct();

}//CStringDistance

CStringDistance::~CStringDistance() {

    delete[] pattern;

    delete[] text;

}//~CStringDistance

void CStringDistance::SetString(char *p,char *t) {

    delete[] pattern;

    pattern=new char[strlen(p)+1];

    if (t!=NULL) {

        delete[] text;

        text=new char[strlen(t)+1];

        strcpy(text,t); lenText=strlen(text);

    }//if

    strcpy(pattern,p); lenPattern=strlen(pattern);

    Construct();

}//CStringDistance

void main() {

    CStringDistance s("hello","fellow");

    printf("%d\n",s.Distance());

    s.SetString("ell");

    printf("%d\n",s.Distance());

    s.SetString("fellow");

    printf("%d\n",s.Distance());

}//main

  타이핑 거리(typing distance)

  스트링간의 거리 문제는 다양한 응용을 가지고 있다. 여기서는 Levenshtein
의 거리를 사용하지 말고, 타이핑 거리(typing distance)를 사용하여, 이를
사용하는 응용 프로그램을 만들어 보자.

정의  타이핑 거리(typing distance)는 컴퓨터 키보드에 위치한 2개의 문자
사이의 유클리드 거리(Euclidean distance)이다.

 [1][2][3][4][5][6][7][8][9][0][-][=][\]

 [Q][W][E][R][T][Y][U][I][O][P][[][]]

 [A][S][D][F][G][H][J][K][L][;][']

 [Z][X][C][V][B][N][M][,][.][/]

             2벌식 자판의 영문 배열: 두 문자간의 기하학적 거리를 타이핑 거리라 정
          의한다. 'A'와 'S'의 타이핑 거리는 1이다. 하지만, 'A'와 'D'의 타이핑 거리
          는 2이다. 이것은 사용자가 'A'를 타이핑하기 원한 경우, 'S'로 잘못 타이핑
          했다면, 'D'로 잘못 타이핑 한 것보다는 가중치가 작음을 의미한다.

  위 그림을 이용하여 다음과 같은 26×26 타이핑 거리 배열 TD을 만들 수
있다.

A

B

C

D

E

F

G

H

I

J

K

L

M

N

O

P

Q

R

S

T

U

V

W

X

Y

Z

A

0

6

4

2

4

3

4

5

9

6

7

8

8

7

10

11

2

5

1

6

8

5

3

3

7

2

B

6

0

2

4

6

3

2

3

7

4

5

6

2

1

8

9

8

5

5

4

6

1

7

3

5

4

C

4

2

0

2

4

3

4

5

9

6

7

8

4

3

10

11

6

5

3

6

8

1

5

1

7

2

D

2

4

2

0

2

1

2

3

8

4

5

6

6

5

9

10

4

4

1

5

7

3

3

3

6

4

E

...

0

             타이핑 거리 테이블: 실수 연산을 피하기 위해 가로간의 거리는 1, 세로
          간의 거리는 2로 계산하여 휴리스틱하게 거리를 계산하였다.

  2차원 테이블 TD를 이용하여 타이핑 거리를 다음과 같이 정의한다.

    w(a,ε) = 2

    w(ε,b) = 2

    w(a, b) = TD[a][b]	if a≠b

            = 0		otherwise

  입력으로 두 개의 스트링이 주어졌을 때 이들 간의 타이핑 거리를 구하는
알고리즘은 위에서 제시한 Wagner와 Ficher의 알고리즘과 같다. 단, 가중치
함수 w는 타이핑 거리를 사용해야 한다.

  대중 적인 운영체제인 Win95의 명령 프람프트(command prompt)에서 사용
하는 40여 개의 명령을 사용자가 잘못 타이핑 한 경우, 사용자가 타이핑한 가
장 가까운 명령을 제시하는 셸 프로그램(shell program)을 생각해 보자.

  사용자가 CLS 명령을 수행할 의도였지만, 실수로 VLS를 타이핑했다고 가
정하자. Levenshtein 거리에 의해서는 아마도 가장 가까운 거리를 가지는 명
령은 VOL 혹은 VER이 될 것이다. 하지만, 타이핑 거리에 의해서는 CLS가
가장 가깝다. 그러므로 명령을 CLS로 자동으로 고치는 것이 가능하다.

  명령 자동 전환기

  아래의 프로그램은 사용자가 40여 개의 명령 중 어떤 것을 입력하면, 그와
가장 가까운 명령을 추천하는 프로그램이다. 물론, 잘못된 문자열을 입력하여
도 그와 가장 가까운 명령을 추천한다.

  DIR 명령이 왼쪽으로 한 칸씩 이동된 형태인 SUE를 입력해 보라. 프로그
램은 DIR을 추천한다. 또한 VLS를 입력해 보라. 프로그램은 CLS를 추천한
다.

  타이핑 거리 테이블(TD; typing distance table)을 구하기 위해 약간의 기교
를 사용하였다. 하지만, 직관적이므로 이해하는 데는 어렵지 않다.

#include <stdio.h>

#include <stdlib.h>

#include <conio.h>

#include <string.h>

#include <math.h>

#include <iostream.h>

typedef unsigned char byte;

//global tables--------------------------------------------------

char szCommand[][9]={"DIR",     "CALL",

                     "CHCP",    "RENAME",

                     "REN",     "ERASE",

                     "DEL",     "TYPE",

                     "REM",     "COPY",

                     "PAUSE",   "DATE",

                     "TIME",    "VER",

                     "VOL",     "CD",

                     "CHDIR",   "MD",

                     "MKDIR",   "RD",

                     "RMDIR",   "BREAK",

                     "VERIFY",  "SET",

                     "PROMPT",  "PATH",

                     "EXIT",    "CTTY",

                     "ECHO",    "LOCK",

                     "UNLOCK",  "GOTO",

                     "SHIFT",   "IF",

                     "FOR",     "CLS",

                     "TRUENAME","LOADHIGH",

                     "LH",      "LFNFOR"};

#define NUM_COMMAND (sizeof szCommand / sizeof szCommand[0])

char aKey[3][13]={"QWERTYUIOP",//Keyboard layout

                  "ASDFGHJKL",

                  "ZXCVBNM"};

int aTD[27][27];//typing distance table

//global functions-----------------------------------------------

int GetTypingDistance(int x1,int y1,int x2,int y2) {

    return abs(y2-y1)*2+abs(x2-x1);

}//GetTypingDistance

void SetTD() {

    int x,y,i,j;

    int row,column;

    for (y=0;y<3;++y) {

        x=0;

        while (aKey[y][x]!=0) {

            row=aKey[y][x]-'A';

            for (i=0;i<3;++i) {

                j=0;

                while (aKey[i][j]!=0) {

                    column=aKey[i][j]-'A';

                    aTD[row][column]=GetTypingDistance(x,y,j,i);

                    ++j;

                }//while

            }//for

            ++x;

        }//while

    }//for

}//SetTD

//classes--------------------------------------------------------

class CStringDistance {

    byte d[100][100];

    char *pattern,*text;

    int lenPattern,lenText,distance;

  private:

    int Weight(int i,int j);

    int Min(int a,int b,int c);

    void Construct();

  public:

    CStringDistance(char *p,char *t);

    ~CStringDistance();

    int Distance() { return distance; }

    void SetString(char *p,char *t=NULL);

};//class CStringDistance

int CStringDistance::Weight(int i,int j) {

    //return pattern[i]!=text[j];

    char r=toupper(pattern[i]),

         c=toupper(text[j]);

    return aTD[r-'A'][c-'A'];

}//Weight

int CStringDistance::Min(int a,int b,int c) {

    if (a<b)

        return a<c?a:c;

    else

        return b<c?b:c;

}//GetMin

void CStringDistance::Construct() {

    int i,j;

    d[0][0]=0;

    for (i=1;i<=lenPattern;++i)

        d[i][0]=i;

    for (i=1;i<=lenText;++i)

        d[0][i]=i;

    for (i=1;i<=lenPattern;++i)

        for (j=1;j<=lenText;++j)

d[i][j]=Min(d[i-1][j]+2,d[i][j-1]+2,d[i-1][j-1]+Weight(i-1,j-1));

            //                    ^heuristic factor

    distance=d[lenPattern][lenText];

}//Construct

CStringDistance::CStringDistance(char *p,char *t) {

    pattern=new char[strlen(p)+1];

    text=new char[strlen(t)+1];

    strcpy(pattern,p); strcpy(text,t);

    lenPattern=strlen(pattern); lenText=strlen(text);

    Construct();

}//CStringDistance

CStringDistance::~CStringDistance() {

    delete[] pattern;

    delete[] text;

}//~CStringDistance

void CStringDistance::SetString(char *p,char *t) {

    delete[] pattern;

    pattern=new char[strlen(p)+1];

    if (t!=NULL) {

        delete[] text;

        text=new char[strlen(t)+1];

        strcpy(text,t); lenText=strlen(text);

    }//if

    strcpy(pattern,p); lenPattern=strlen(pattern);

    Construct();

}//CStringDistance

void main() {

    SetTD();

    CStringDistance s("","");

    char sUserCommand[80];

    int min,t,i,nIndex;

    while (1) {

        cout << "Enter command(\'exit\' or ^Z to end):";

        cin >> sUserCommand;

        if (strcmp(sUserCommand,"exit")==0

            || strlen(sUserCommand)==0)

            break;

        //initialize min distance & index

        s.SetString(szCommand[0],sUserCommand);

        min=s.Distance();

        nIndex=0;

        for (i=1;i<NUM_COMMAND;++i) {

            s.SetString(szCommand[i]);

            t=s.Distance();

            if (t<min) {

                min=t;

                nIndex=i;

            }//if

        }//for

        cout << szCommand[nIndex] << endl;

    }//while

}//main

  몇 가지 명령에 대한 실행 결과를 아래에 리스트하였다.

Enter command('exit' or ^Z to end):sue

DIR

Enter command('exit' or ^Z to end):vls

CLS

Enter command('exit' or ^Z to end):delet

DEL

Enter command('exit' or ^Z to end):dalete

DATE

Enter command('exit' or ^Z to end):delete

DEL

  풍부한 예제를 연습문제에 제시하였다. 어떤 문제는 학기 중 과제(term
project)가 될 정도로 큰 규모의 것도 있다.

  실습문제

1. 최소 비용 편집 순서(least cost editing sequence)를 정하는 알고리즘을
설계하라. 위의 예의 경우 다음과 같은 대응은 최소한의 경비로 편집이 가능
하다.(Hint: 구하는 과정을 거꾸로 생각해 보라)

    i     1 2 3 4 5 6 7 8

    xi    p r e t e r i t

          |  /  |  \  |  \

    yi    z e i t g e i s t

    j     1 2 3 4 5 6 7 8 9

2. 앞 장에서 제시한 'KMP 알고리즘을 이용한 문자열 찾기 프로그램'을 개선
하여, '대충찾기' 기능을 추가하라. 예를 들어, 명령행(command prompt)에 사
용자가,

C:\> find hello 10

이라고 입력하면, hello와 10%정도 틀린 문자열을 찾아야 한다(%의 정도는
자신이 정의하라).

3. 텍스트 파일 편집기(editor)를 작성하라. 독자들이 사용하는 컴파일러가
Visual C++이거나 C++ 빌더이면, Text Control을 사용하라. 단, 이 편집기는
'대충찾기' 기능을 지원해야 한다.

4. 정규표현을 찾는 클래스를 작성하라.

5. 스트링 거리가 어떻게 문자 인식에 이용될 수 있는지 설명하고, 사용자가
마우스로 입력한 숫자를 Off-Line■으로 인식하는 프로그램을 작성하라.

             Off-Line이므로 사용자가 숫자를 쓸 때 추적 가능한 정보는 사용
          하지 않아야 한다. On-Line은 Off-Line에 비해 추적 가능한 정보가
          많기 때문에 비교적 인식하기가 쉽다.

6. 두개의 함수의 비슷한 정도를 검사하는 프로그램을 작성하라. 예를 들어,
아래 두 함수의 그림은 비슷하지만 같지는 않다.

            두개의 비슷한 함수

  이러한 다르기를 어떻게 검사할 것인가? 먼저, 표준 편차(standard
deviation)를 이용하여 검사가 가능한지 설명하고, 함수의 다르기 척도를 구하
기 위한 수정된 스트링 거리 구하기 알고리즘을 구현하라.

7. C++언어 전용 소스 편집기(editor)를 작성하라. 독자들이 사용하는 컴파일
러가 Visual C++이거나 C++ 빌더이면, Text Control을 사용하라. 이 편집기
는 사용자가 소스를 입력하다가 다음의 상황이 발생하면, 적절한 조치를 취해
야 한다.

case

action

White space를 입력

White space 앞의 문자가 키워드 혹
은 이미 선언된 ID인지를 검사하여
발견되지 않은 경우 가장 가까운 문자
열로 변환한다.

ID를 입력하다가 (등 심벌을 입력

이미 선언된 멤버 함수 혹은 함수인지
를 검사하여 자동으로 변환한다.

멤버 연산자 . 혹은 ->를 입력

이미 선언된 클래스,구조체 혹은 공용
체 인지를 검사하여 자동으로 변환한
다.


14. 화살표 그리기: 삼각함수

14. 화살표 그리기: 삼
각(Trigonometric)함
수

  일반적으로 그래픽 라이브러리는 화살표를 그리는 함수를 지원하지
않는다. 실제로 화살표를 그리는 함수는 BGI에도 GDI에도 없다. 그렇다
면 직접 이 함수를 구현해 보자. 아래 그림은 우리가 만들 PolarLine()■
이라는 함수를 사용하여 그린 화살표이다. 이 함수는 5개의 파라미터

             반지름의 길이와 각을 이용하여 (r,θ)로 표현되는 좌표계를 극
          (Polar) 좌표계라 한다.

를 가진다.

PolarLine(xStart, yStart, nDegree, nLength, nArrowLengthRatio=0);

  첫 번째 두 파라미터는 그리기를 시작할 좌표를 나타낸다. 실제로 이
함수는 (xStart, yStart)에서 시작하면서 각도가 nDegree인 nLength의
선을 그린다. 또한 마지막 파라미터 nArrowLengthRatio가 0이 아니면,
화살의 머리 부분을 그리는데, 비율을 적어준다. 예를 들어 이 값이 10
이면, nLength의 10%의 길이로 화살머리를 그린다.

              화살표를 그리는 함수 PolarLine()

  위 <그림 ?>는 PolarLine(200,200,-30,200,10) 의 실행 결과이다. 다음
의 <그림 ?>는 화살표를 어떻게 설계해야 할지를 잘 보여준다.

              화살표의 설계

  화살 머리는 화살대와 30°의 각을 이루는 것으로 하였다. 그러므로
한쪽은 180-30=150°를 더해 주어야 하고, 다른 한 쪽은 180+30=210°
를 더해 주어야 한다. 위의 <그림 ?>처럼 (xStart, yStart)와 (xArrow,
yArrow)의 값이 구해지면,

line(xStart, yStart, xArrow, yArrow);

를 사용하여 먼저 화살대를 그린다. 그 다음으로, 화살 머리의 길이인
lenArrow를 계산한 다음, 현재 진행 방향에서 150°, 210°의 방향으로
■ 화살대를 그리면 된다. 이 부분은 같은 작업이므로 재귀 호출을 사

             삼각함수는 주기함수이므로 360°보다 커지는 경우를 염려하지 않
          아도 된다.

용하였는데 소스를 주의해서 살펴보기 바란다. 라이브러리가 제공하는
삼각함수는 일반적으로 라디안(Radian)■을 파라미터로 지정하여야 하

             반지름의 길이와 같은 길이로 원호를 따라 갔을 때에, 원호의 끝
          점과 원의 중심이 이루는 각을 1 라디안이라 한다. 원의 크기에 상
          관없이 반원은 항상 반지름의 길이의 약 3.141592배인데 이를 수학
          에서 π(Pi)로 나타낸다. 이 무한실수의 상수 값은 <math.h>에
          M_PI로 선언되어 있으므로, 이를 사용하면 된다.

므로, 각(Degree)을 라디안으로 바꾸는 함수 ToRadian()을 사용하고 있
다. 또한 화면 좌표계는 데카르트 좌표계와 y축의 방향이 다르므로, 실
제로 더해주어야 하는 부분은 빼 주어야 한다(소스의 밑줄 친 부분을
보라). 소스는 아래에 리스트하였다.

#include <stdio.h>

#include <conio.h>

#include <math.h>

#include <graphics.h>

inline float ToRadian(int degree) {

    return (float)degree*M_PI/180.0;

}//ToRadian

void PolarLine(int x,int y,int degree,int length,int arrowRatio=0)
{

    int h,w;//height,width

    int xArrow,yArrow,lenArrow;//arrow coordinates and length

    w=(int)(cos(ToRadian(degree))*(float)length);

    h=(int)(sin(ToRadian(degree))*(float)length);

    xArrow=x+w; yArrow=y-h;//화면 좌표계로 변환(x축에 대칭)한다.

    line(x,y,xArrow,yArrow);

    if (arrowRatio>0) {

        lenArrow=length*arrowRatio/100;

        //Notice technique used here!

        PolarLine(xArrow,yArrow,degree+180-30,lenArrow);

        PolarLine(xArrow,yArrow,degree+180+30,lenArrow);

    }//if

}//PolarLine

void main() {

    int gd=0,gm;

    initgraph(&gd,&gm,"..\\..\\bgi");

    PolarLine(200,200,-30,200,10);

    getch();

    closegraph();

}//main

  문제를 설계하는데 삼각함수를 이용하여야 하는 경우는 매우 많다.
삼각함수를 어떻게 설계하는지 완전하게 개념을 파악하여 여러분의 응
용에 사용할 수 있어야 한다. 몇 개의 추가적인 예를 통하여 삼각함수
가 어떻게 이용되고 있는지 살펴보자.

  비 오는 날

  실제로 비 오는 날을 설계하는 것은 삼각함수와 관계 되었다기 보다
는 문제 그 자체와 상관이 더욱 많다. 어떻게 비 오는 광경을 설계할
것인가? 문제와 관계된 클래스를 설계하면 문제는 대부분 해결된 셈이
다.

  다음 <그림 ?>처럼 클래스들을 설계하기로 했다. 각각의 빗줄기를
CRain으로 설계를 하기로 했고, CRain을 여러 개 가지는(Have-a)
CStorm 클래스를 설계하기로 한 것이다. 클래스 그림(Diagram)을 <그
림 ?>에 나타내었다.

             빗줄기(CRain) 객체와 폭풍(CStorm) 객체

             비 오는 날의 클래스 그림

  CRain은 한 개의 빗줄기마다 한 개의 객체가 유지된다. degree는 현
재 빗줄기의 각도, step은 빗줄기의 진행 속도를 픽셀단위로, length는
빗줄기의 길이, (x,y)는 빗줄기의 상단 부분의 좌표를 나타낸다.

  SetLenAndXY()는 빗줄기의 길이와 x좌표를 난수로 초기화한다. 빗
방울이 땅에 떨어지면, 다시 하늘에서 땅으로 떨어지는 것을 구현하기
위해 이 함수를 불러야 한다. 물론 생성자에서도 이 함수를 호출한다.
생성자 CRain()은 각도, 속도, 길이와 x좌표를 적당하게 초기화한다. 실
제로 프로그램에서는 디폴트 생성자만 사용하므로 다른 생성자는 필요
없다. Draw()는 다음과 같은 방법으로 빗줄기 한 개를 그린다.

             빗줄기를 그리는 법

  빗줄기의 각도는 빗줄기의 한 점에서 평면에 내린 수선과 빗줄기가
이루는 각이다. 그림에서 θ는 degree이다. 그러므로,

    h=(int)(cos(ToRadian(degree))*(float)length);

    w=(int)(sin(ToRadian(degree))*(float)length);

    tx=x+w; ty=y-h;

    line(x,y,tx,ty);

    if (ty>480) SetLenAndXY();

처럼 코딩하면 된다. 마지막의 if문은 빗줄기가 땅에 닿았을 때, 빗줄기
의 길이와 x위치를 초기화한다(y위치는 무조건 0이며, 길이는 매번 초
기화하지 않아도 된다). 또한 ty를 구할 때 화면 좌표계를 고려하여 -h
하였다.

  Show()는 이전의 빗줄기를 지운 다음■, 빗줄기를 step만큼 전진시

             실제로는 이전의 위치에 다시 한번 그리고 있는데, 이는 XOR 연산
          의 특성상 가능하다. 이 부분은 XOR 애니메이션을 참고하기 바란
          다.

켜서 빗줄기를 그린다.

  CStorm은 빗줄기의 개수를 유지하며, 빗줄기의 속도와 각도를 일관
되게 유지하기 위해, degree와 step변수를 유지한다. CRain이 설계되었
다면, CStorm을 설계하는 것은 사소하다. 생성자에서 빗줄기의 개수와
각도와 속도를 파라미터로 받고 있다.

  main()에서 객체를 만들 때, CStorm s(50,5,20) 했으므로, 이 예제에서
는 50개의 빗줄기를 5°의 각도와 20 픽셀의 속도로 나타내고 있다.

#include <stdio.h>

#include <stdlib.h>

#include <conio.h>

#include <math.h>

#include <dos.h>

#include <graphics.h>

int nDegree,nStep;

inline float ToRadian(int degree)

{

    return (float)degree*M_PI/180.0;

}//ToRadian

class CRain {

    int degree,step,length,

        x,y;

  private:

    void SetLenAndXY(int l=0,int tx=0);

  public:

    CRain();

    CRain(int d,int s,int l=0,int tx=0);

    void Draw();

    void Show();

};//class CRain

CRain::CRain()

{

    degree=nDegree; step=nStep;

    SetLenAndXY();

}//CRain::CRain

CRain::CRain(int d,int s,int l,int tx)

{

    degree=d; step=s;

    SetLenAndXY(l,tx);

}//CRain::CRain

void CRain::SetLenAndXY(int l,int tx)

{

    length=((l==0)?random(200):l);

    x=((tx==0)?random(640):tx); y=0;

}//CRain::SetLenAndXY

void CRain::Draw()

{

    int tx,ty,h,w;

    h=(int)(cos(ToRadian(degree))*(float)length);

    w=(int)(sin(ToRadian(degree))*(float)length);

    tx=x+w; ty=y-h;

    line(x,y,tx,ty);

    if (ty>480) SetLenAndXY();

}//CRain::Draw

void CRain::Show()

{

    Draw();//erase

    y+=(int)(cos(ToRadian(degree))*(float)step);

    x-=(int)(sin(ToRadian(degree))*(float)step);

    Draw();//draw

}//CRain::Show

class CStorm {

    CRain* rain;

    int nRain,degree,step;

  public:

    CStorm(int n,int d,int l);

    void Show();

    void SetDegree(int d) { degree=d; }

    void SetStep(int s) { step=s; }

};

CStorm::CStorm(int n,int d,int s)

{

    nRain=n; nDegree=degree=d; nStep=step=s;

    rain=new CRain[n];//call default constructor

}//CStrom::CStrom

void CStorm::Show()

{

    for (int i=0;i<nRain;++i) rain[i].Show();

}//CStrom::Show

void main()

{

    OpenGraph();

    setwritemode(XOR_PUT);

    randomize();

    CStorm s(50,5,20);

    while (!kbhit()) {

        s.Show();

        delay(10);

    }//while

    getch();

    closegraph();

}

  Black Hole

  이제 빛 마저 빨아들이는 블랙 홀을 만들어 보자. 여러분은 이 프로
그램을 약간만 응용하면, Win95의 '우주여행' 같은 화면 보호기를 만들
수 있을 것이다.

             Black Hole

  이 프로그램의 핵심 부분은 삼각함수를 이용하는 회전 변환을 이해하
는 것이다.

             (x,y)를 θ만큼 회전한 좌표 (x',y')

  이러한 회전변환은 다음과 같은 공식으로 표현된다.

 PMATRIX { { x prime}#

 {y prime }} = PMATRIX {{cos theta}& {-sin theta}#

{sin theta}&{cos theta}}  PMATRIX { { x}#

 {y}}

  이 회전변환을 함수로 구현해 보면 다음과 같다.

#define D2R(d)          (double)((d)*M_PI/180.0)

void Rotate(int x,int y,int deg,int *x2,int *y2)

{

    *x2=(int)((double)x*cos(D2R(deg))-(double)y*sin(D2R(deg)));

    *y2=(int)((double)x*sin(D2R(deg))+(double)y*cos(D2R(deg)));

}

  위의 함수를 제대로 다 이해한 사람은 아래의 소스를 살펴 볼 필요는
없다. 누차 강조하지만, 여러분의 응용에 삼각함수를 이용할 수 있는
가가 문제이기 때문이다.

#include <stdio.h>

#include <conio.h>

#include <graphics.h>

#include <math.h>

#include <dos.h>

#include <stdlib.h>

#define MOVE_X          320

#define MOVE_Y          175

#define MAX_POS         100

#define R2D(r)          (int)((r)*180.0/M_PI)

#define D2R(d)          (double)((d)*M_PI/180.0)

int pos[3][MAX_POS],deg[MAX_POS];

void Rotate(int x,int y,int deg,int *x2,int *y2)

{

    *x2=(int)((double)x*cos(D2R(deg))-(double)y*sin(D2R(deg)));

    *y2=(int)((double)x*sin(D2R(deg))+(double)y*cos(D2R(deg)));

}

void DrawPixel(int x,int y,int c)

{

    setcolor(c);

    setfillstyle(SOLID_FILL,c);

    fillellipse(x+MOVE_X,y+MOVE_Y,1,1);

}

void SetPos(int i,int d,int x,int y)

{

    pos[0][i]=d;

    pos[1][i]=x;

    pos[2][i]=y;

}

void InitPos(void)

{

    int i,t;

    for(i=0;i<MAX_POS;++i) {

        SetPos(i,t=random(360),MOVE_X-random(MOVE_X*2),

                               MOVE_Y-random(MOVE_Y*2));

        deg[i]=t;

    }

}

void DrawStar(int c)

{

    int i;

    for(i=0;i<MAX_POS;++i)

        DrawPixel(pos[1][i],pos[2][i],c);

}

void _OpenGraph(void)

{

    int gd=VGA,gm=VGAMED;

    initgraph(&gd,&gm,"..\\..\\bgi");

}

void main(void)

{

    int i,t,p=0;

    _OpenGraph();

    InitPos();

    DrawStar(WHITE);

    while(!kbhit()) {

        setvisualpage(p);

        setactivepage(p^1);

        cleardevice();

        for(i=0;i<MAX_POS;++i)

            Rotate(pos[1][i],pos[2][i],2,&pos[1][i],&pos[2][i]);

        for (i=0;i<MAX_POS;++i)

            pos[0][i]=(pos[0][i]+1)%360;

        for (i=0;i<MAX_POS;++i) {

            if (pos[0][i]==deg[i]

            || (pos[1][i]==0 && pos[2][i]==0) ) {

                SetPos(i,t=random(360),MOVE_X-random(MOVE_X*2),

                                       MOVE_Y-random(MOVE_Y*2));

                deg[i]=t;

            }

        }

        DrawStar(WHITE);

        p^=1;

    }

    getch();

    closegraph();

}

  실습문제

1. 위에서 설계한 화살표를 그리는 함수 PolarLine()을 다음과 같이 수
정하라.

PolarLine(xStart, yStart, xEnd, yEnd, nArrowLengthRatio=0);

  즉, 시작 위치와 끝 위치를 명시하여 화살표를 그린다.

2. 3차원 육면체를 화면의 중앙에 그려서 회전시키는 프로그램을 작성
하라.

3. 아날로그(Analog) 시계 프로그램을 작성하라.

4. '비 오는 날'을 수정하여 사용자가 방향키로 빗줄기의 세기와 방향을
정할 수 있도록 프로그램을 전반적으로 수정하라.

5. '비 오는 날'을 수정하여, 전역변수 nDegree와 nStep을 CStorm의 멤
버변수로 구현하라.(Hint: Friend)

6. 위의 블랙 홀 프로그램은 클래스로 구현되어 있지 않다. 별 한 개를
클래스로 구현하여 위의 프로그램을 클래스를 사용하는 프로그램으로
전환하라.


15. 브레즌헴(Bresenhem)을 아세요?

15. 브레즌햄(Bresenha
m)을 아세요?

  선의 시작 좌표와 끝 좌표, 색이 주어질 때 선을 그리는 루틴은 그래픽 루
틴의 기본이 될 것이다. 그래픽에서 수직선이나 수평선을 그리는 것은 쉽지만,
사선을 그리는 효율적인 루틴을 만드는 것은 고려해야 할 점이 많다. 예를 들
어 (0,0) 과 (5,10) 사이에 선을 그을 때 그 사이의 어떤 점들을 선택해야 할
것인가?

  선을 그리는 한 방법은 기울기를 사용하는 것이다. 즉, (0,0)과 (5,10)사이에
선을 그린다면 y의 변화에 대한 x의 변화는 1/2이다. 이 경우 y좌표의 증가에
대하여 x좌표는 그 반만 증가할 것이다. 이 방법은 수학적으로 명확하고 이해
하기 쉽기 때문에 종종 사용하는 경우가 있지만, 반드시 실수연산을 사용해야
한다는 문제가 있다.

  현재까지 가장 많이 사용되는 알고리즘은 브레즌햄의 선 그리기 알고리즘
이다. 이 알고리즘도 개념적으로는 x와 y좌표의 기울기를 사용하지만, 실수연
산이나 나눗셈은 전혀 사용되지 않는다. 대신에 x와 y좌표의 변화 사이의 비
(Ratio)는 정수 덧셈과 뺄셈에 의해 이루어진다. 그 기본적인 개념은 각 점의
이상적 위치와 실제 화면상의 위치 사이에 오차를 기록하는 것이다. 이 오차
는 화면 표시장치도 유한한 해상도를 갖기 때문에 각 점의 실제 위치는 가장
근접한 근사값이라는 데에 기인한다.

  선을 그리는 각 반복 단계에서 errX와 errY라는 두 변수가 각각 x와 y좌표
의 변화만큼 증가한다. 이 오차가 미리 정해진 값에 도달할 때마다 오차는 재
설정되고 적절한 좌표 카운터가 증가된다. 이 과정이 선의 끝에 도달할 때까
지 계속된다. 완성된 브레즌햄의 선 그리기 함수를 아래에 리스트하였다. 내
용은 별 관심이 없고, 단지 자신의 루틴에 선 그리기 만을 추가할려는 독자는
나머지 부분은 읽어보지 않아도 무방(Don't Care)하다.

#include <stdio.h>

#include <graphics.h>

#include <conio.h>

#include <math.h>

#define PIXEL(x,y,nColor) putpixel(x,y,nColor)

void Bresenham(int xStart,int yStart,int xEnd,int yEnd,int nColor) {

    int i,distance,

        errX=0,errY=0,deltaX,deltaY,

        incX,incY;

    deltaX=xEnd-xStart;

    deltaY=yEnd-yStart;

    if (deltaX>0)

        incX=1;

    else if (deltaX==0)

        incX=0;

    else

        incX=-1;

    if (deltaY>0)

        incY=1;

    else if (deltaY==0)

        incY=0;

    else

        incY=-1;

    deltaX=abs(deltaX);

    deltaY=abs(deltaY);

    if (deltaX>deltaY)

        distance=deltaX;

    else

        distance=deltaY;

    for (i=0;i<=distance+1;++i) {

        PIXEL(xStart,yStart,nColor);//응용에 맞게 이 함수만 수정하

                                       //면 된다

        errX+=deltaX;

        errY+=deltaY;

        if (errX>distance) {

            errX-=distance;

            xStart+=incX;

        }//if

        if (errY>distance) {

            errY-=distance;

            yStart+=incY;

        }//if

    }//for

}//Bresenham

void main() {

    int gd=0,gm;

    initgraph(&gd,&gm,"..\\..\\bgi");

    rectangle(100,100,200,200);

    Bresenham(100,100,200,200,RED);

    getch();

    closegraph();

}//main

  여러분이 만들려고 하는 곳에 이용하려면, 위의 소스 중 PIXEL()부분만을
고쳐서 사용하면 된다. 여기서는 PIXEL()이 점을 그리는 것으로 사용되었지
만, 실제로 구현시에는 여기에 상응하는 함수가 명시되어야 한다.

효율적인 직선 그리기 알고리즘

  <그림 ?>처럼 네모진 구멍들로 이루어진 채(Sieve)처럼 생긴 종이 위에다
하나의 직선을 그리려고 한다고 상상해 보자. 결과를 자세히 보면 진짜로 직
선이 되지 못하고 대개 직선처럼 보이게 네모 점들의 모임으로 되어 있을 것
이다.

<그림 ?> 채 위에 그린 직선

  래스터(Raster) 비디오 디스플레이의 네모 격자로 된 픽셀들은 직선과 기
하학적 곡선을 그리게 될 때에 전자로 된 채와 생김새가 비슷한 것이다. 우리
가 그릴 수 있는 최선의 방법은 직선이나 곡선을 가장 근사하게 표현하는 픽
셀들의 집합을 이용하여 각 직선이나 곡선을 표현하는 일인 것이다.

스캔 변환(Scan Conversion)

  비디오 버퍼 속에 있는 픽셀들의 어떤 집합이 특정 기하학적 그림을 가장
잘 근사화 할 수 있는가를 결정하는 과정이 필요하다. 이 과정을 스캔변환이
라 한다. 지금은 직선을 그리기 위한 스캔 변환을 살펴보자.

  직선을 그리기 위한 가장 쉬운 방법은 아래와 같은 직선 방정식을 이용하는
것이다.

y=mx+b

  여기서 m은 직선의 기울기이고, b는 y-절편이다. 이 방정식을 이용하여 선
을 그리는 알고리즘을 구현하는 것은 연습문제에 제시하였다.

  위의 알고리즘은 기울기 때문에 부동 소숫점 연산을 사용하므로 비효율적이
다.

  브레즌햄의 알고리즘은 두 개의 주어진 픽셀 (x1,y1)과 (x2,y2) 사이에 이어
지는 직선에 제일 가깝게 위치하는 픽셀들의 집합을 그림으로 나타낸 알고리
즘이다. 이것은 x1이 x2보다 작고, 직선의 기울기는 0과 1사이에 있는 값이라
고 가정하고 있다. 직선의 방정식을 간소화하기 위해서 이 알고리즘은 첫째
시작점 (x1,y1)을 (0,0)이라고 가정한다. 그래서 직선방정식은

y= LEFT
( { dy}
over {dx
}  RIGHT
) x

가 된다. 여기서

dy=y2-y1

이고

dx=x2-x1

이다. 설명을 위해 <그림 ?>와 같이 직선의 일부분을 생각해 보자. 이 알고리
즘은 x1부터 x2까지 x의 각 값에 대한 해당되는 y값을 되풀이해서 결정해 나
가도록 만들어져 있다. 예를 들어서 (xi-1,yi-1) 좌표의 픽셀을 작도(Plotting)한
뒤에 픽셀 A나 픽셀 B가 어느 것이 정확한 직선에 더 가까운가를 결정하여
더 가까운 픽셀을 선택하는 것이다.

<그림 ?> (xi-1,yi-1)이 결정되었을 때, a와 b값에 따라서 A 혹은 B를
선택한다. 그림에서는 A를 선택한다.

  xi점에서 정확한 직선상의 y좌표와 픽셀 A의 y좌표 사이의 차이를 a라 하
면 아래와 같다.

a=(y sub i +1)-( {
dy} over {dx } )x
sub i

여기서 dy/dx는 직선의 기울기를 표시한다. 마찬가지로 픽셀 B로부터 직선상
의 점까지의 거리 b는

b=({dy} over
{dx})x sub i -
y sub i

이다. 만일 거리 b가 거리 a 보다 작으면 픽셀 B가 진짜 직선에 더 가까이
놓여 있는 것이다. 혹 a가 b보다 작으면 픽셀 A가 직선에 더 가깝게 놓여 있
는 것이다. 다시 말하면, (a-b)의 부호에 따라서 픽셀 A나 픽셀 B가 직선의
어느 쪽에 가깝게 놓여있나를 결정하게 된다.

  그런데 이 과정을 보면 직선에 대한 방정식을 간단히 사용하는 것보다 더
많은 계산을 해야 되는 것처럼 보인다. 그러나 a와 b의 값의 비교 작업은 다
음과 같은 계산 과정 중에 암시적으로 비교가 쉽게 되어 버린다. 즉, 값에 대
한 (b-a)의 값을 반복적으로 계산함으로써 a와 b의 값이 비교된다. 그 원리
를 유도하는데는 약간의 대수(Algebra)가 필요하게 되나 결과를 얻는 계산은
간단하다.

(b-a)=2×({dy} over {dx})x
sub i - 2×y sub i -1

  x1은 x2보다 작기 때문에 dx는 항상 양의 값을 갖게 되고 (b-a) 대신
(b-a)의 부호를 결정하는 데에 dx×(b-a)를 사용할 수 있다. 즉, 필섹 A를 선
택할 것인가 픽셀 B를 선택할 것인가를 결정하는데 dx×(b-a)를 이용할 수
있다.

  그러면 위 식의 양변에 dx를 곱하면,

           dx×(b-a)=2×dy×xi-2×yi×dx - dx

                    =2×(dy×xi-dx×yi) - dx                     (1)

이다. 여기서 dx×(b-a)를 결정 변수(Decision Variable)라 부르기로 하고,
이것을 di로 표시하기로 하자. d1,d2,...,dn을 모두 계산하기 위해서는 di-1로부터
di를 계산해 내는 방법을 알 필요가 있다.

  식 (1)은

                   di=2×(dy×xi-dx*yi) - dx                      (2)

이고, di-1=2×(dy×xi-1-dx×yi-1) - dx 가 된다. 그러므로,

LPILE {d sub i - d sub {i-1} = (2×(dy×x sub i - dx×
y sub i)) - (2×(dy×x sub {i-1} - dx× y sub {i-1}))#

~~~~~~~=2×(dy×(x sub i - x sub {i-1}) - dx×(y sub i
- y sub {i-1})) }

가 된다. 그런데 xi-xi-1은 픽셀의 기본간격이므로 항상 1이 된다. yi-yi-1은 (xi,
yi+1) 좌표의 픽셀 A가 선택되면 1이 되고, (xi, yi) 좌표의 픽셀 B가 선택되면
0이 된다.

  그래서 di와 di-1 사이의 차를 계산하는 것은 쉬워지고, 다음 두 가지 중의
하나로 di-1을 증가시킴으로써 쉽게 di 값을 계산할 수 있다.

  (1) di-1≥0 인 경우

  (xi, yi+1)에 있는 픽셀 A를 선택하고, di-1에 대한 증가는 다음과 같다.

                          (di-di-1)=2×(dy-dx)                     (3)

  즉, di=di-1+2×(dy-dx) 이다.

  (2) di-1〈0 인 경우

  (xi, yi)에 있는 픽셀 B를 선택하고, di-1에 대한 증가는 다음과 같이 된다.

                           di=di-1+2×dy                          (4)

  di의 초기값을 계산해 보자. 선상에 있는 첫 번째 픽셀은 (0, 0) 좌표에 있다
고 가정한 사실을 기억하자. di에 관한 방정식 (2)에 xi=1, yi=0을 대입하면,

                            di=2×dy - dx                        (5)

가 된다.

  대부분의 복잡한 계산은 픽셀을 그리는 반복문 밖에서 단 한 번만 계산되기
때문에 이 최종 알고리즘은 대단히 효율적이다.

  하지만 위에서 제시한 Bresenham() 함수는 이 알고리즘을 더욱 간단히 구
현한 것이므로 보충의 설명이 필요하다.

  여기서 활용된 기본 개념은 진짜 직선에 가장 접근해 있는 픽셀을 찾기보다
는 x축으로 한 단위 변화할 때, y축의 변환된 실제 직선의 위치와 변화하기
전 앞서 선택되었던 픽셀의 y축 값과의 차이를 err - y축의 것은 errY, x축의
것은 errX - 이라는 변수에 dy/dx×1을 지정한다. 이 때 err값이 1은 초과하
지 않게 되면 y축 값은 그전 그대로의 위치 (x+1, y)의 픽셀을 선택하고, 다
시 x축 값을 한 자리 옮겨본다. err 값에 dy/dx×1을 다시 더해보아 err 값이
1을 초과하면 y값을 한 자리 위로 옮긴 (x+2, y+1) 위치의 픽셀을 선택하고,
그리고나서 err에서는 1을 빼고나서 다시 똑같은 방식으로 직선에 근사한 점
들을 찾아가는 것이다.

  이러한 관계를 그림으로 표시하면 아래와 같다.

<그림 ?> Bresenham() 함수가 다음 점을 찾는 방법

  <그림 ?>의 픽셀 지정 요령을 해설해 보면, 위의 그림에서 A, B, ..., J, K
는 모두 픽셀을 표시하고, 그리려고 하는 직선의 시작점과 끝점의 좌표는
(xStart, yStart), (xEnd, yEnd)라 하자. 그러면 직선의 기울기는

dy over dx = {"delta"Y} over
{"delta"X} = {yEnd - yStart}
over {xEnd-xStart}

가 된다.

  우선 A점은 원하는 픽셀로서 선정이 되었다고 가정하고, 다음 픽셀을 선정
하기 위해서 x축으로 한 픽셀의 변화를 가져올 때 해당하는 진짜 y의 값과,
만일 B점을 픽셀 후보로 선택할 경우 생기는 y축상의 오차 errY는 기울기가
주어졌으므로

errY={dy} over
{dx} × 1={dy}
over {dx}

가 된다. 그런데 errY 값이 1보다 작다. 그러면 두번째로 B점이 픽셀로 선정
되고, 그리고 나서 다음 점을 선택하기 위해서 x방향으로 한단위 변위시켜 C
점을 픽셀 후보로 생각해 보는 것이다. 이 경우 C점과 실제 직선의 y값과의
차이 errY는 앞서의 errY 값에다 y축으로의 증분이 더해질 것이므로

errY = errY + dy over dx
× 1 = 2 dy over dx

가 된다. 이 새로운 errY 값이 1보다 큰가를 비교해 본다. 1보다 작으므로 그
대로 C점을 세번째 픽셀로 선택한다. 다시 x방향으로 한 픽셀 더 변위를 취
하여 F점을 후보로 고려해 본다. 그런데 이 때

errY = 3
dy over dx

가 되고, 이 길이
가 그림에서 보면 1보다 더 크게 되었다.

  이 경우는 F점은 포기하고, y축으로 한 자리 위로 옮겨 D점을 픽셀로 선택
하게 된다. 그리고 나서 errY값에서 1을 빼어 실제 D점부터 실제 직선상의
점까지 오차길이로 만들어야 한다. 이후 똑같은 방법으로 끝까지 선택하는 것
이다.

  원리는 위와 같은데 함수를 이해하기 위해서 추가로 설명할 것이 남아 있
다. 여기서는 x축으로 계속 1씩 변화시키면서 y축상의 변화량을 조사하고 있
지만, 사실 y축을 1씩 변화시키면서 x축상의 변화를 조사할 수도 있다. 그런
데 deltaX와 deltaY의 크기를 비교해서 변화가 큰쪽을 독립변수로 잡는 것이
편리하다. 여기서는 편의상 |deltaX| > |deltaY|인 관계가 있다고 가정하고 설
명한 것이다(deltaX=constant로 정의함).

  선택한 픽셀과 실제 직선과의 x방향, y방향 오차값 errX, errY는 원래 다음
과 같이 표시될 것이다.

errX = errX + "delta"X over
constant × 1#

errY = errY + "delta"Y over
constant × 1

  그런데 위와 같이 나누기 계산을 하게 되면 시간이 많이 걸리므로 정수 계
산을 위해서 수식을 아래와 같이 변형시킨다.

LPILE { errX × constant = errX ×
constant + "delta"X#

errY × constant = errY × constant +
"delta"Y }

  위 수식은 또한 다음과 같이 표시할 수 있다.

LPILE { errX =
errX + "delta"X#

errY = errY +
"delta"Y }

  errX나 errY 값이 constant보다 크게 된다는 것은 errX나 errY 값이 1보다
커진다는 뜻이다. 그래서

errY > constant

이면,

errY=errY-constant

의 계산을 수행할 필요가 있는 것이다.

64×48에 선 그리기

  그러면 VGA 그래픽 화면을 64×48로 간주하여 (0,0) - (30,12)에 선을 그리
는 것을 만들어 보자. 여러분은 이 예제를 통하여 여러분의 응용에 어떻게 이
용해야 하는가를 알게 될 것이다.

  먼저 만들어야 할 것은 점을 찍는 PIXEL()을 만드는 것이다. 이 함수는 다
음과 같이 만들 수 있다.

bar(x*10,y*10,(x+1)*10-1,(y+1)*10-1);

  소스를 아래에 리스트하였다.

<그림 ?> 실행 결과

#include <stdio.h>

#include <graphics.h>

#include <conio.h>

#include <math.h>

//#define PIXEL(x,y,nColor) putpixel(x,y,nColor)

inline void PIXEL(int x,int y,int nColor=15) {

    setfillstyle(SOLID_FILL,nColor);

    bar(x*10,y*10,(x+1)*10-2,(y+1)*10-2);

}//PIXEL

void Bresenham(int xStart,int yStart,int xEnd,int yEnd,int nColor) {

    int i,distance,

        errX=0,errY=0,deltaX,deltaY,

        incX,incY;

    deltaX=xEnd-xStart;

    deltaY=yEnd-yStart;

    if (deltaX>0)

        incX=1;

    else if (deltaX==0)

        incX=0;

    else

        incX=-1;

    if (deltaY>0)

        incY=1;

    else if (deltaY==0)

        incY=0;

    else

        incY=-1;

    deltaX=abs(deltaX);

    deltaY=abs(deltaY);

    if (deltaX>deltaY)

        distance=deltaX;

    else

        distance=deltaY;

    for (i=0;i<=distance+1;++i) {

        PIXEL(xStart,yStart,nColor);

        errX+=deltaX;

        errY+=deltaY;

        if (errX>distance) {

            errX-=distance;

            xStart+=incX;

        }//if

        if (errY>distance) {

            errY-=distance;

            yStart+=incY;

        }//if

    }//for

}//Bresenham

void main() {

    int gd=0,gm;

    initgraph(&gd,&gm,"..\\..\\bgi");

    Bresenham(0,0,30,12,WHITE);

    getch();

    closegraph();

}//main

컴퓨터 바둑에서 돌간의 거리 구하기

  <그림 ?>와 같은 컴퓨터 바둑 상황에서 두 검은 돌간의 거리는 어떻게 구
할 것인가? 브레즌햄 알고리즘은 좋은 해결책이 될 수 있다.

<그림 ?> 컴퓨터 바둑에서 돌간의 거리

  이 부분의 소스는 다른 부분이 많이 포함되므로 싣지는 않지만 충분한 아
이디어는 얻었으리라 본다. 여러분은 여러분 자신의 응용에 브레즌햄 알고리
즘을 이용할 수 있게 된 것이다!

실습문제

1. y=mx+b 형태의 직선을 기울기 정보를 사용하여 그리는 함수를 작성하라.

2. 위의 두 번째 예제에서 마우스로 64*48에 숫자를 그려서 이를 인식하는 프
로그램을 작성하라. 단, 숫자 인식을 위해서 "스트링 거리"를 구하는 알고리
즘을 이용하여야 한다.

3. 화면의 원하는 부분을 확대하는 함수를 만들어 확대된 부분에 선을 그리면
확대되기 전의 화면에 같은 효과가 나도록 하는 그래픽 편집기를 작성하라.

4. 아래의 프로그램은 원 그리기 알고리즘이다. 원리를 설명하고, 속도를 약간
개선시키기 위해 소스를 수정하라(힌트: 곱하기를 쉬프트(shift)로 바꾼다).

#include <stdio.h>

#include <conio.h>

#include <graphics.h>

void DrawCircle(int xc,int yc,int radius) {

    int x,y,s;

    x=radius; y=0; s=radius;

    while (x>=y) {

        putpixel(xc+x,yc+y,WHITE);

        putpixel(xc+x,yc-y,WHITE);

        putpixel(xc-x,yc+y,WHITE);

        putpixel(xc-x,yc-y,WHITE);

        putpixel(xc+y,yc+x,WHITE);

        putpixel(xc+y,yc-x,WHITE);

        putpixel(xc-y,yc+x,WHITE);

        putpixel(xc-y,yc-x,WHITE);

        getch();

        s=s-(y<<1)-1;

        y=y+1;

        if (s<0) {

            s=s+((x-1)<<1);

            x=x-1;

        }//if

    }//while

}

void main() {

    int gd=0,gm;

    initgraph(&gd,&gm,"../../bgi");

    DrawCircle(100,100,50);

    getch();

    closegraph();

}


16. 연결 리스트

16. 배열과의 이혼: 링크
드 리스트(Linked List)

  이제 배열과의 짧았던 결혼 생활을 청산하려 합니다. 나는 그녀의 융통성
없음에 너무 싫증이 났습니다. 그녀는 자신이 할 수 있는 일과, 할 수 없는 일
의 경계를 너무 명확히 합니다. 그녀는 경계를 넘어서는 일은 절대로 하지 않
습니다. 나는 그녀의 사랑의 한계가 너무 싫습니다.

  그녀의 유일한 장점은 그녀가 가진 물건들을 임의로 내가 사용할 수 있다는
것이었습니다. 하지만, 그러한 장점은 그녀의 큰 단점에 비하면, 새(bird)발의
피(blood)입니다.

  내가 무엇보다도 그녀와 헤어지려하는 중요한 이유는 내게 링크드 리스트라
는 새 애인이 생겼기 때문입니다. 나는 그녀의 물건들을 마음대로 사용할 수
없습니다. 하지만, 그녀의 사랑은 언제나 차고 넘칩니다. 나는 끝이 없는 그녀
의 사랑과, 그녀가 너무 좋습니다. 하지만, 그녀에게도 단점은 있습니다. 그녀
의 물건은 사용하고 나서는 언제나 제자리에 정돈해 놓아야 합니다. 만약 그
러지 않는다면, 그녀는 언젠가는 폭발해 버리고 말 것입니다. 하지만, 끝이 없
는 그녀의 사랑이 너무나 크기에 그러한 단점들은 극복될 수 있을 것입니다.
나는 그녀와 사랑에 빠졌습니다.

  크기가 알려지지 않은 정수들을 데이터로 사용한다고 하자. 이러한 정수는
실행 시간에 사용자에 의해 입력되므로, 몇 개의 정수가 입력될지 알 수 없다.
이러한 데이터는 링크드 리스트로 관리하는 것이 타당하다. 링크드 리스트는
데이터가 필요할 때마다 메모리를 할당하여 사용하므로, 효율적으로 메모리를
사용할 수 있다. 일반적으로 링크드 리스트는 다음 데이터를 가리키기 위하
여 몇 개의 포인터 변수를 사용하는데, 사용되는 포인터 변수의 수와 성질에
따라, 싱글 링크드 리스트(singly linked list)와 더블 링크드 리스트
(double linked list) 등으로 구분 가능하다.

  일반적으로 링크드 리스트의 시작을 가리키는 포인터를 헤드(head)라고 하
며, 별도의 데이터 형으로 구현한다. 헤드와 3개의 데이터를 가지는 다음과 같
은 싱글 링크드 리스트를 고려해 보자. 포인터가 가리키는 값이 없을 때는
NULL이라고 표기한다.

<그림 ?> 싱글 링크드 리스트

  헤드는 포인터 변수이며, 3개의 노드(node)는 2개의 필드를 가지는데, 각각
은 정수형 data, 포인터형 next를 가진다. 먼저 노드형을 어떻게 정의할 것인
가? 노드는 구조체로 정의 가능하지만, 필드에 자신의 포인터형을 가져야 한
다. 그러므로, 다음과 같은 구조체의 정의(definition)가 가능하다.

    struct NODE {

        int data;

        NODE *next;

    };

  이처럼 구조체의 정의 부분에 완성되지 않은 구조체 자신의 포인터 변수를
포함할 수 있는데 이러한 구조체를 '자기 참조 구조체(self-reference
structure)'라 한다. C++의 클래스는 구조체의 확장이므로 클래스에 대해서도
이러한 정의가 가능하다.

  링크드 리스트는 노드에 다음 노드를 가리키는 포인터를 포함해야 하므로,
자기 참조 클래스(self-reference class)를 사용하여 구현해야 한다. 위의 예
에서의 링크드 리스트가 실제로는 <그림 ?>처럼 유지되고 있다는데 주목하
라.

  링크드 리스트는 같은 데이터 형의 모음(collection)을 데이터로 유지한다.
그러므로 링크드 리스트를 구현한 클래스를 모음 클래스(collection class)
혹은 포함 클래스(container class)라 한다. 이러한 클래스는 클래스의 역할
때문에 붙여진 이름이며, 문법적으로 정의 가능한 것이 아니다. 이러한 클래스
의 다름 예들에는 배열, 트리(tree)와 그래프(graph)등이 있다. 트리, 그래프
등은 중요한 자료구조이므로, 자료구조(data structure) 관련서적을 참고하여,
반드시 개념과 사용법을 익힌다음 자신의 손으로 직접 구현해 보기 바란다.

  어떤 학자들은 '학생들이 연구(research) - 책상 앞에서의 공부 - 는 안하
고, 컴퓨터 앞에서만 시간을 보낸다'고 한다. 하지만, 필자의 생각은 반대이다.
'대부분의 학자들은 개발(development) - 컴퓨터 앞에서의 구현 - 은 안하고
책상 앞에서 무엇을 하고 있는가?'. 어느 것도 간과되어서는 안된다. 연구와
개발(research & development: R&D)은 모두 중요하다.

<그림 ?> 그림 ?의 실제 메모리 구조

  여러분은 클래스를 설계할 때, 정적인 특성과 동적인 특성을 파악하여 설계
해야 한다는 것을 이미 알고 있을 것이다. 링크드 리스트에 가해지는 연산 -
동적인 특성 - 에는 어떤 것이 필요한가?

  (1) 데이터의 삽입

  (2) 데이터의 삭제

  (3) 특정 데이터의 삭제

  (4) 특정 데이터의 위치 찾기

  (5) 모든 데이터에 대한 반복(iterator) 작업

  먼저 데이터의 삽입을 구현해 보자. 40을 삽입하는 과정을 예로 들어보자.
①먼저 메모리를 확보하여 노드를 생성한다.

②노드가 헤드가 가리키던 노드를 가리키게 한다.

③헤드가 새로 생성된 노드를 가리킨다.

  이상과 같은 과정을 임시 코드(pseudo code)로 작성해 보자.

    node=new NODE(40);

    node->next=head;

    head=node;

  삭제도 이와 유사한 방법으로 구현할 수 있다. 헤드가 가리키는 노드를 삭
제해 보자. 이 과정은 <그림 ?>처럼 진행된다.

<그림 ?> 헤드가 가리키는 노드를 삭제하는 과정

  임시 코드로 이것을 구현해 보자.

    temp=head;

    head=head->next;

    delete temp;

  싱글 링크드 리스트는 쉽게 구현 할 수 있다. 하지만, 활용성이 훨씬 높은
더블 링크드 리스트를 구현해 보자. 더블 링크드 리스트는 트리, 그래프등 대
부분의 Container 클래스를 구현하는 기본이 된다.

더블 링크드 리스트

  우리가 설계할 리스트는 head와 tail, 두 개의 포인터를 유지한다. 각각은
previous, next 포인터 변수이다. 포인터가 두 개이므로 삽입과 삭제는 싱글
리스트인 경우보다 약간 복잡하다. 하지만, 데이터를 head와 tail의 양쪽에 삽
입/삭제하는 것은 융통성을 제공한다. head가 가리키는 노드의 previous는 항
상 NULL이다. tail이 가리키는 노드의 next도 항상 NULL이다.

<그림 ?> 더블 링크드 리스트

  싱글 링크드 리스트인 경우와 마찬가지로, 40을 head쪽에 삽입해 보자. 삽
입하는 과정은 다음과 같은 임시 코드로 표현 가능하다.

    class CList {

        int data;

        CList *previous, *next;

      public:

        CList(int d,CList *_next) {

            data=d;

            next=_next;

        }

        ...

    };//class CList

        ...

        CList *prevHead=head;

        head=new CList(d,prevHead);

        prevHead->previous=head;

        head->previous=NULL;

        ...

이 과정을 아래 그림에 단계별로 나타내었다.

<그림 ?> 새로운 노드의 생성

<그림 ?> 새로운 노드의 next와 이전 노드의 previous를 갱신

<그림 ?> 새로운 노드의 previous와 head를 갱신

  클래스 CList는 한 개의 노드를 나타낸다. CListHeader는 CList의 모든 멤
버를 접근할 필요가 있으므로, CList의 친구로 선언되는 것이 바람직하다.

  또한, head와 tail이 둘 다 NULL일 때는 - 아무 것도 없는 빈 상태 - 을
가리킬 때는 특별하게 취급되어야 한다. 노드가 만들어지면 head와 tail은 같
은 노드를 가리키며, head->previous, head->next, tail->previous와
tail->next는 모두 NULL이다. 이 경우는 그림으로 설명하지 않는다. 독자들
이 직접 그림으로 그려서 확인해 보기 바란다. 소스에서 이 부분은 if문으로
분리하여 구현하였다.

#include <stdio.h>

typedef int LIST_TYPE;//type of a data node of doubly linked list

//class CList {-----------------------------------------------------

class CList {//a data node of doubly linked list

    friend class CListHeader;

    //CListHeader must access all data members

  private:

    LIST_TYPE data;

    CList *previous,*next;//pointers of doubly linked list

  public:

    CList(LIST_TYPE d,CList *n) { data=d; next=n; }

      //when a node is inserted to head

    CList(CList *p,LIST_TYPE d) { data=d; previous=p; }

      //when a node is inserted to tail

};//class CList

//class CList }-----------------------------------------------------

//class CListHeader {-----------------------------------------------

class CListHeader {//a head node of doubly linked list

  protected:

    CList *head,*tail,//maintain two pointers for flexibility

          *current;//used for bookmark for visiting all nodes

  public:

    static int RESET_HEAD,RESET_TAIL;//Reset() flags

      //RESET_HEAD sets 'current' to 'head'

      //RESET_TAIL sets 'current' to 'tail'

  public:

    CListHeader() { head=tail=current=NULL; }

    virtual ~CListHeader();

      //destructor must be virtual

      //because this class will be inherited to some sub classes

    void InsertHead(LIST_TYPE d);//add a node to 'head'

    void InsertTail(LIST_TYPE d);//add a node to 'tail'

    int IsHeadNull() { return head==NULL; }

    int IsTailNull() { return tail==NULL; }

    LIST_TYPE DeleteHead();//delete a node from 'head'

    LIST_TYPE DeleteTail();//delete a node from 'tail'

    /* member functions which is related to 'current' */

    void Reset(int type=RESET_HEAD);

      //                ^default indicates 'head'

    LIST_TYPE Read() { return current->data; }

    void Next() { current=current->next; }

    void Previous() { current=current->previous; }

    int IsEnd() { return current==NULL; }

};//CListHeader

int CListHeader::RESET_HEAD=0;

int CListHeader::RESET_TAIL=1;

CListHeader::~CListHeader() {

    CList *old;

    //if (head==NULL || tail==NULL) return;

    current=head;

    while (current!=NULL) {

        old=current;

        current=current->next;

        delete old;

    }//while

}//CLisetHeader::~CListHeader

void CListHeader::InsertHead(LIST_TYPE d) {

    CList *prevHead=head;

    if (head==NULL && tail==NULL) {

        head=tail=new CList(d,NULL);

        head->previous=NULL;

    } else {

        head=new CList(d,prevHead);

        prevHead->previous=head;

    }//if.. else..

}//CListHeader::InsertHead

void CListHeader::InsertTail(LIST_TYPE d) {

    CList *prevTail=tail;

    if (head==NULL && tail==NULL) {

        head=tail=new CList(NULL,d);

        tail->next=NULL;

    } else {

        tail=new CList(prevTail,d);

        prevTail->next=tail;

    }//if.. else..

}//CListHeader::InsertTail

LIST_TYPE CListHeader::DeleteHead() {

    CList *old;

    LIST_TYPE d;

    if (head!=NULL) {

        d=head->data;

        old=head;

        head=head->next;

        if (head!=NULL)

            head->previous=NULL;

        else

            tail=NULL;

        delete old;

        return d;

    }//if

    return -1;

}//CListHeader::DeleteHead

LIST_TYPE CListHeader::DeleteTail() {

    CList *old;

    LIST_TYPE d;

    if (tail!=NULL) {

        d=tail->data;

        old=tail;

        tail=tail->previous;

        if (tail!=NULL)

            tail->next=NULL;

        else

            head=NULL;

        delete old;

        return d;

    }//if

    return -1;

}//CListHeader::DeleteTail

void CListHeader::Reset(int type) {

    if (type==RESET_HEAD)

        current=head;

    else

        current=tail;

}//CListHeader::Reset

//class CListHeader }-----------------------------------------------

//class CQueue {----------------------------------------------------

class CQueue : public CListHeader {

  public:

    void Enqueue(LIST_TYPE d) { InsertHead(d); }

    LIST_TYPE Dequeue() { return DeleteTail(); }

    int IsEmpty() { return IsTailNull(); }

};//CQueue

//class CQueue }----------------------------------------------------

///*----driver for CQueue-------------------------------------------

void main() {

    int a[]={9,1,5,7,3};

    int i;

    printf("\n\n");

    CQueue q;

    for (i=0;i<5;++i)

        q.Enqueue(a[i]);

    while (!q.IsEmpty())

        printf("%d,",q.Dequeue());

}//main

//*/

/*----Driver for doubly linked list --------------------------------

void main() {

    int a[]={9,1,5,7,3};

    int i;

    printf("\n\n");

    CListHeader list;

    for (i=0;i<5;++i)

        list.InsertHead(a[i]);

    //list.Reset(CListHeader::RESET_TAIL);

    //while (!list.IsEnd()) {

    //    printf("%d,",list.Read());

    //    list.Previous();

    //}//while

    while (!list.IsTailNull())

        printf("%d,",list.DeleteTail());

}//main

*/

  소스에는 CListHeader를 상속받은 CQueue를 구현해 놓았다. 이외의
container 클래스를 연습문제에 제시하였다. 각자가 모두를 꼭 구현해 보기 바
란다.

연습문제

1. 위에서 제시한 더블 링크드 리스트는 InsertHead()와 InsertTail()에 버그가
있다. 이를 수정하라.

2. 위에서 제시한 더블 링크드 리스트는 '특정 데이터의 위치 찾기'를 지원하
지 않는다. FindNode()를 멤버 함수로 추가하라. 특정 노드가 발견되는 경우,
노드의 시작 주소를 리턴한다. 발견되지 않는 경우, NULL을 리턴한다. 유지
되고 있는 노드의 개수가 N개라면, 얼마의 시간(time complexity)이 필요한
가?

3. 위에서 제시한 더블 링크드 리스트를 상속받아 스택(stack)을 구현하라.

4. 위에서 제시한 더블 링크드 리스트를 상속받아 그래프(graph)를 구현하라.
단 그래프를 인접 리스트(adjacent list)로 구현하라. 아래의 그래프를 인접
리스트로 표현한 다음, 깊이 우선 탐색(depth first search: DFS)을 이용하
여 노드를 출력하라. 그래프의 방향과 가중치(weight)를 어떻게 표현하여야
하는가?

<그림 ?> 가중치 방향 그래프

5. 위에서 제시한 더블 링크드 리스트를 상속받아 트리(tree)를 구현하라. 트
리는 임의 개수의 자식을 가질 수 있다. 이러한 트리가 2개의 포인터로 구현
가능한가? 아래의 그림에서 도움(hint)을 얻으라.

<그림 ?> 트리의 2진트리 표현

6. 컴퓨터 바둑에서 정석(joseki) 데이터를 트리로 구현하라. 이러한 트리 데이
터를 효율적으로 디스크에서 읽어오기 위해서는 어떻게 디스크에 저장해야 하
는가? 트리 데이터를 디스크에 저장하는 프로그램을 작성하라.

7. 위에서 제시한 더블 링크드 리스트는 정수 데이터 1개만을 멤버로 가질 수
있다. 리스트가 임의의 형의, 임의의 개수를 가지도록 설계 가능한가? 예를 들
면, 링크드 리스트의 노드는 2개의 정수와 1개의 실수를 가져야 한다. 링크드
리스트가 이러한 노드를 가질 수 있도록, 틀(template)을 사용하여 구현하라.

8. 링크드 리스트는 다항식의 계산에 일반적으로 응용된다. 다항식을 표현하는
클래스를 작성하고, 두 개의 다항식을 더하는(add) 드라이버 프로그램을 작성
하라.


17. String Matching

17. 스트링 매칭: Z-알고
리즘

  스트링 매칭에 전혀 흥미가 없는 독자라도 이 장의 내용은 완벽하게 이해할
필요가 있다. 여기서 사용된 아이디어는 너무 기민하여, 독자들이 다른 문제를
풀이하는 능력을 기르는데 많은 도움이 될 것이다. 만약 이 책이 교재로 사용
되고 있다면, 이 장의 연습문제는 훌륭한 소규모 프로젝트(small size project)
가 될 수 있을 것이다.

정확한 매칭의 문제 정의

  패턴(pattern)이라고 하는 스트링 P가 주어지고, 텍스트(text)라고 불리는
P보다 긴 T가 주어졌을 때, 정확한 매칭 문제는 텍스트 T에서 패턴 P의 모
든 위치를 찾는 것이다. 이 장에서 T의 길이 |T|=m이라고 하고, P의 길이
|P|=n이라고 가정한다.

  예를 들면, 만약 P=aba이고, T=bbabaxababay일 때, P는 T의 3, 7과 9의 위
치에서 나타난다. 패턴이 T 상에서 겹쳐질(overlap) 수 있다는 것에 주목하라.
예에서 7과 9의 위치에서 패턴은 겹치고 있다.

정확한 매칭 문제의 중요성

  대부분의 문서 편집기(word processor)에서 혹은 텍스트 에디터(text
editor)에서 독자들은 정확한 매칭 기능을 사용하고 있다. UNIX 혹은 대부분
의 컴파일러에서 제공하는 유틸리티인 grep(general regular expression
processor)는 내부적으로 정확한 매칭을 사용하고 있는 유명한 유틸리티이다.
혹은 Win95의 찾기 기능 역시 정확한 매칭을 사용하고 있다.

  독자들은 데이터베이스에서 데이터를 검색하는 작업의 대부분이 정확한 매
칭 문제라는 것을 알고 있을 것이다. 4Giga 바이트가 넘는 사람의 유전자 데
이터에서 특정 유전자를 찾기 위해서 역시 정확한 매칭을 사용해야 한다. 수
십만 개의 단어가 있는 사전에서 단어를 검색하는 작업 역시 마찬가지이다.
우리는 이 장에서 기존의 알려진 알고리즘인 BM과 KMP를 다루지 않을 것
이다. 이러한 알고리즘은 다른 알고리즘/자료 구조 책을 참고하라.

  우리는 이 장에서 새로운 알고리즘인 Z-알고리즘을 사용할 것이다. 이것은
KMP나 BM보다 이해하기 쉽다. 물론 최악의 경우 시간 복잡도(time
complexity)는 패턴의 길이에 비례한다. 이것은 KMP 혹은 BM 알고리즘과
효율이 같다. 하지만, 이해하기가 훨씬 쉽다.

  우리는 또한 압축된(compressed) 텍스트 스트링(string)에서 문자열을 탐색
하는 기법에 대하여 논의하고 구현해 볼 것이다. 이것은 아직까지 어떤 책에
서도 다루지 않았던 신선한 주제이다. 또한 여기서 구현한 알고리즘은 기존의
알려진 방법에 비해서 가장 빠른 방법이다. 만약 독자들인 이 장의 내용을
완벽하게 이해할 수준이라면, 아무리 어려운 어떤 어려운 문제라도 해결할 수
있을 것이라 믿는다. 한 번만에 이 장의 내용을 이해하려고 하지 말라. 반복적
인 읽기가 독자들의 실력을 키워 줄 것이다.

스트링에 관한 기본 용어 정의

정의  스트링 S는 순서를 가지는(ordered), 왼쪽에서 오른쪽으로 차례대로 쓰
여진, 문자의 리스트(list of characters)이다. 임의의 스트링 S에 대해, S[i..j]
는 S의 i에서 시작해서 j에서 끝나는 서브스트링(substring)이다. 특별히
S[1..i]는 i에서 끝나는 S의 프리픽스(prefix)이고, S[i..|S|]는 i에서 시작하는
S의 서픽스(suffix)이다.

  예를 들면, california는 스트링이다. lifo는 서브스트링이다. cal은 prefix이고,
ornia는 suffix이다.

정의  정확한(proper) prefix/suffix는 전체 스트링 S가 아닌 prefix/suffix
이다.

정의  임의의 스트링 S에 대해, S[i]는 i번째 문자를 나타낸다.

  우리는 이 장에서 특별히 패턴과 텍스트를 나타내기 위해 S 대신에, P 혹
은 T를 사용할 것이다. 또한 임의의 스트링을 나타내기 위해 그리스 소문자
(Ձ,Ղ,Ճ,Մ)를 사용할 것이다. 또한 한 개의 임의의 문자를 나타내기 위해 영어
소문자(a,b,c,d,...)를 사용할 것이다.

  서브스트링(substring)과 서브시퀀스(subsequence)는 구분할 필요가 있다.
서브시퀀스는 스트링의 연속적이지 않은 문자의 접속(concatenation)을 의미한
다. california에서 califor는 서브스트링이다. 하지만, calfor는 서브시퀀스이다.

순진한 방법(naive method)

  정확한 매칭에 관한 논의는 순진한 방법에서 시작한다. 우리 역시 이 전통
을 따른다. 순진한 방법은 P의 왼쪽 끝과, T의 왼쪽 끝을 맞춘다. P와 T의
두 개의 문자를 비교하기 시작한다. 두 개의 같지 않은 문자가 발견되거나, 더
이상 매치할 P의 문자가 없을 때까지 계속 매치한다. 물론 후자의 경우는 P가
T에서 발견된 경우이다. 양쪽의 경우 모두, P는 오른쪽으로 한 칸 이동(shift)
된다. 그리고 위의 과정을 반복한다. 이 과정은 P의 오른쪽 끝이 T의 오른쪽
끝을 넘어갈 때까지 계속한다.

  n이 P의 길이를 나타낸다고 하고, m이 T의 길이를 나타낸다고 했을 때, 이
방법에 의해서 최악의 경우 걸리는 시간은 Ԩ(nm)이다. 특별히, P와 T가 모두
반복되는 같은 문자를 가지고 있는 경우, P는 T의 m-n+1곳에 나타난다. 그러
므로 순진한 방법은 n(m-n+1)번의 비교를 수행한다. 예를 들면, P=aaa이고,
T=aaaaaaaaaa일 때, n=3, m=10이므로, 24번을 비교한다.

  순진한 방법은 이해하기 쉽고, 프로그래밍하기도 쉽다. 하지만 최악의 경우
시간 복잡도는 불만족스럽고, 또한 개선의 여지가 있다. T 혹은 P의 크기가
매우 크다면, 순진한 방법은 매우 느려서, 비효율적일 것이다. 오래 전에 이러
한 순진한 방법을 개선하려는 많은 시도가 있었다. 이러한 시도는 최악의 경
우 시간을 Ԩ(nm)에서 O(n+m)으로 줄였다. '×'를 '+'로 바꾸는 것은 엄청난 속
도 개선이다. n=1000과 m=10,000,000에 대해 계산해 보라.

순진한 방법의 속도 개선을 위한 초기의 아이디어

  순진한 방법의 속도 개선을 위한 첫 번째 아이디어는 모두, 불일치
(mismatch)가 일어났을 때, 한 문자 이상을 이동(shift)시키려는 노력이다. 하
지만, T에서 나타나는 P를 놓치는(miss) 정도로 많이 이동해서는 안 된다. 한
문자 이상을 이동시키는 것은 T를 P가 더욱 빠르게 지나가므로, 비교 횟수를
줄인다. 많이 이동시키는 것과 더불어, 어떤 방법들은 이동 후에 비교를 줄이
기 위해 패턴의 일부를 건너뛰는 방법을 시도한다. 우리는 이러한 많은 아이
디어를 자세히 살펴 볼 것이다.

  <그림 1.1>은 P=abxyabxz이고, T=xabxyabxyabxz일 때, 이러한 아이디어
의 설명이다. P가 T의 6번째에 나타남을 주목하라. 순진한 알고리즘은 처음으
로 P를 T의 왼쪽 끝과 맞춘다. 첫 번째 글자가 불일치(mismatch)하므로 P를
1칸 이동한다. 그 다음의 7번은 매치된다. 하지만, 그 다음(전체적으로 9번째)
의 매치는 실패한다. 그러므로 다시 P를 1칸 이동한다. 불일치를 발견한다. 그
리고 이러한 반복을 2번 더 한 다음, P의 왼쪽 끝이 T의 6번째 문자와 정렬
된다. 그 시점에서 8번의 매치를 발견하고, P가 T의 6번째에서 나타남을 안
다. 이 예제에서 순진한 알고리즘은 전부 20번의 비교를 수행했다.

  0        1       0        1      0        1

  1234567890123   1234567890123   1234567890123

T:xabxyabxyabxz T:xabxyabxyabxz T:xabxyabxyabxz

P:abxyabxz      P:abxyabxz      P:abxyabxz

  *               *               *

   abxyabxz        abxyabxz        abxyabxz

   ^^^^^^^*        ^^^^^^^*        ^^^^^^^*

    abxyabxz           abxyabxz        abxyabxz

    *                  ^^^^^^^^           ^^^^^

     abxyabxz

     *

      abxyabxz

      *

       abxyabxz

       ^^^^^^^^

             <그림 1.1> 첫 번째 그림은 완전히 순진한 알고리즘을 설명한다. 그 다음
          두 개의 그림은 더 기민한 이동을 설명한다. 문자 아래의 캐럿(carot: ^)은
          매치를 나타내고 별표(asterisk: *)는 매치가 되지 않음을 나타낸다.

  9번째 비교 후 좀 더 기민한(smarter) 알고리즘을 생각해 볼 수 있다. 순진
한 알고리즘에서 그 후의 연속한 3번의 비교는 실패한다. 하지만 기민한 방법
은 그 3번의 이동과 비교를 건너뛴다. P의 왼쪽 끝을 T의 6번째 위치와 즉각
맞춘다. 이것은 비교 횟수를 줄인다. 어떻게 기민한 알고리즘이 이것을 할 수
있는가? 9번째의 비교 후, 알고리즘은 P의 처음 7개의 문자가, T의 2에서 8까
지 매치 되었다는 정보를 알고 있다. 또한 P의 첫 번째 문자 a가 P의 5번째
위치 전까지는 나타나지 않는 다는 것도 알고 있다. 이것은 T에서 6번째까지
는 a가 나타나지 않는다는 충분한 정보이다. 그러므로, P의 왼쪽 끝이 T의 6
번째와 정렬되기 전까지는 P와 T사이의 매치가 불가능하다는 충분한 정보를
얻을 수 있다. 이러한 종류의 추리는 한 문자 이상을 이동시키는 핵심이다. 보
다 많이 이동시키는 것과 더불어, 우리는 또한 정렬된 몇몇 문자는 비교가 필
요하지 않다는 것을 보게 될 것이다.

  더 기민한(smarter) 알고리즘은 P의 처음 3개의 문자 abx가 5번째 위치에
서 다시 나타남을 안다. 그렇다면, P의 처음 7개의 문자가 T의 2에서 8까지
매치 되었다는 것을 알고 있기 때문에, 이 기민한 알고리즘은 P의 왼쪽 끝이
T의 6번째 위치로 정렬되었을 때, 그 후의 3번의 비교는 매치될 것이라는 것
을 알고 있다. 이 기민한 알고리즘은 그러한 3번의 비교를 행하지 않는다. 그
대신에, P의 왼쪽 끝이 T의 6번째와 정렬된 후, P의 4번째 문자와 T의 9번째
문자를 비교하기 시작한다. 그러므로, 이 기민한 알고리즘은 순진한 알고리즘
에 비해 전부 6번의 비교를 줄인다.

  위의 예제는 몇 비교는 생략할 수 있다는 아이디어를 설명한다. 하지만, 알
고리즘이 어떻게 그러한 아이디어를 구현하는 가는 명확하지 않다. 효율적으
로 구현된 많은 알고리즘이 있다. KMP(Knuth-Morris-Pratt) 알고리즘이 그
러한 경우이다. KMP의 확장인 BM(Boyer-Moore) 알고리즘 또한 그러하며,
Apostolico-Giancarlo 알고리즘도 그렇다. 이러한 모든 알고리즘은 O(n+m)의
선형 시간에 동작한다. 자세한 사항은 이러한 아이디어를 설명하고 있는 다른
책을 참고하라.

전처리(preprocessing) 접근법

  많은 스트링 매칭 알고리즘이 효율적인 건너뜀(skip)을 할 수 있는 이유는
P나 T의 내부 구조를 알기 위해, 적당한 시간을 소비하기 때문이다. 그 시간
동안, 또 다른 스트링은 알려지지 않아도 된다. 전체 알고리즘에서 이러한 부
분은 전처리 단계(preprocessing stage)로 알려져 있다. 전처리 단계 후에
탐색 단계가 이어진다. 이 단계에서 전처리 단계에서 얻어진 정보를 이용하여
T에서 P를 탐색하는데 필요한 일을 줄인다. 위의 예에서, 기민한 방법은 a가
5번째 전까지는 문자 a가 나타나지 않는다는 것을 알고 있다고 가정한다. 그
리고 더 기민한 알고리즘은 패턴 abx가 5번째에서 다시 나타난다는 정보도
알고 있다고 가정한다. 이러한 정보는 전처리 단계에서 구할 수 있다.

  정확한 매칭 문제에서 앞에서 언급한 3개의 알고리즘은 모두 P를 전처리
한다. T를 전처리하는 방법 - 이것은 suffix 트리를 이용한다 - 은 이 장의 뒤
에서 압축된 텍스트에서 스트링 매칭을 할 때 다룰 것이다. 오래 전에 개발된
이러한 전처리 기법은 개념은 비슷하지만, 세부적인 사항은 무척 다르며, 이해
하기 힘들다. 이 장에서는 원래의 방법들을 사용하지 않을 것이다. 반면에, 몇
개의 다른 알고리즘이 사용하고 있는 전처리의 유사성에 초점을 맞추어, 어떠
한 특정 알고리즘과 상관이 없는 P의 근본적인 전처리(fundamental
preprocessing of P)를 정의할 것이다. 그리고 특정 알고리즘이 근본적이 전
처리에서 얻어진 정보를 어떻게 이용하는지 살펴 볼 것이다. 또한 이 정보를
이용하는 간단한 선형 시간(linear time) 알고리즘을 개발할 것이다.

패턴의 근본적인 전처리(fundamental
preprocessing)

  근본적인 전처리는 일반적인 스트링 S에 대해서 기술할 것이다. 근본적인
전처리의 특정한 응용에서는 S는 P가 될 것이다. 그러나 여기서 우리는 S를
사용할 것이다.

  아래의 정의는 근본적인 전처리에서 계산되는 핵심 값을 정의하고 있다.

정의  스트링 S와 위치 i가 주어졌을 때, Zi(S)는 i에서 시작하고, S의 prefix
와 매치하는 가장 긴 서브스트링의 길이이다.

  다른 말로 하면, Zi(S)는 S의 prefix와 매치하는 S[i..|S|]의 가장 긴 prefix의
길이이다. 예를 들면, S=aabcaabxaaz일 때,

    Z5(S)=3(aabcaabxaaz),

    Z6(S)=1(aabcaabxaaz),

    Z7(S)=Z8(S)=0,

    Z9(S)=2(aabcaabxaaz)

이다. S가 문맥에 의해서 확실하다면, Zi(S)대신에 Zi를 사용할 것이다.

             <그림 1.2> 각각의 실선의(solid) 박스는 2와 i사이에서 S의 prefix와
          매치하는 서브스트링을 나타낸다. 각각의 박스는 Z-박스라 한다. ri는 i
          혹은 그 이전에서 시작하는 Z-박스들의 가장 오른쪽 끝을 나타낸다.
          그리고 α는 ri에서 끝나는 Z-박스의 서브스트링을 나타낸다. li는 α의
          왼쪽 끝을 나타낸다. S의 prefix로 나타나는 α 역시 그림에 나타나 있다.

  다음 개념을 설명하기 위해, <그림 1.2>에 그려진 박스(box)를 생각해 보
자. 각각의 박스는 Zj가 0보다 큰 어떤 위치 j>1에서 시작한다. j에서 시작하
는 박스의 길이는 Zj를 나타낸다. 그러므로, 그림의 각각의 박스는 S의 prefix
와 매치되는 극대 길이(maximal-length)의 서브스트링을 표현한다. 그러
한 박스는 Z-박스라 불린다. 좀 더 형식적으로 아래와 같이 정의한다.

정의  Zi가 0보다 클 때, 임의의 위치 i>1에 대해, i에서의 Z-박스는 i에서 시
작하고 i+Zi-1에서 끝나는 간격(interval)이다.

정의  모든 i>1에 대해, ri는 위치 i에서 혹은 i 이전에서 시작하는 Z-박스의
가장 오른쪽 끝점(right-most endpoint)이다. 다른 방법으로 말하면, ri는 Zj>0
인 모든 i<j≤i 에 걸쳐, j+Zj-1의 값 중 최대값이다.(<그림 1.2>를 보라.)

  우리는 위의 정의에서 명시된 j값을 위해, li라는 용어(term)를 사용할 것이
다. 즉, li는 ri에서 끝이 나는 Z-박스의 왼쪽 끝 위치이다. 가끔은 ri에서 끝이
나는 Z-박스가 1개 이상 존재한다. 그때 li는 여러 개의 값 중 임의의 값이 될
수 있다. 예를 들면, S=aabaabcaxaabaabcy를 고려해 보자. Z10=7, r15=16,
l15=10이다.

  S로 부터 Z값을 선형 시간에 계산하는 것은 패턴 P를 전처리하는 모든 고
전적인 선형 시간 알고리즘에서 우리가 사용하는 근본적인 전처리이다. 그러
한 사용을 상세히 설명하기 전에, 먼저 근본적인 전처리가 선형 시간에 어떻
게 가능한지 살펴보자.

선형 시간을 위한 근본적인 전처리

  이 부분의 목적은 S에서 모든 Zi값을 선형 시간(O(|S|)에 구할 수 있다는
것을 보이는 것이다. 정의에 의한 직접적인 접근은 Ԩ(|S|2)의 시간이 걸린다.
다른 방법이 필요하다.

  각각의 연속적인 위치 i에 대해, Zi, ri와 li를 계산하는 전처리 알고리즘은,
i=2에서 시작한다.  계산된 모든 Z값은 알고리즘에 의해서 유지된다. 그러나
어떠한 i번째 반복에서도 알고리즘은 j=i-1인, rj와 lj만 알면 된다. 이전의 r과
l 값은 필요치 않다. 그러므로 알고리즘은 가장 최근의 rj와 li에 해당하는 r과
l만 유지한다. 그러므로, 매번의 i번째 반복에서 알고리즘이 새로 Z-박스를 발
견한다면, 변수 r이 Z-박스의 끝을 가리키기 위해서 증가할 것이다. 이렇게
하면, r은 지금껏 발견된 Z-박스의 가장 오른쪽 끝을 가리킨다.

  시작하기 위하여, 알고리즘은 명시적으로 비교를 함으로써 Z2를 계산한다.
이 비교는 S[2..|S|]와 S[1..|S|]의 문자를 불일치(mismatch)가 발견될 때까지
한다. Z2는 매치된 스트링의 길이이다. 만약 Z2>0이면, r=r2는 Z2+1로 설정
(set)되고, l=l2는 2로 설정된다. 이외의 경우, r=l=0으로 설정된다. 이제 알고리
즘이 귀납적으로 Zi값을 k-1>1일 때까지 바르게 계산했다고 하자. 그리고 알
고리즘이 현재의 r=rk-1과 l=lk-1을 알고있다고 하자. 알고리즘은 다음에 Zk,
r=rk와 l=lk를 계산한다.

  기본 아이디어는 이미 계산된 Z 값들이 Zk를 계산하는데 사용된다는 것이
다. 사실상, 어떤 경우에, Zk값은 어떠한 문자의 비교도 없이 이전의 Z값으로
부터 유추될 수 있다. 구체적인 예로, k=121이라 가정하자. Z2에서 Z120까지의
모든 값은 이미 계산되었으며, r120=130이고, l120=100이다. 이것은 100에서 시작
하면서, 길이가 31인 서브스트링이 S의 prefix와 매치됨을 의미한다. 이것은
121에서 시작하는 길이가 10인 서브스트링이 S의 22에서 시작하는 길이가 10
인 서브스트링과 매치됨을 의미한다. 그러므로 Z22는 Z121을 계산하는데 매우
도움이 된다. 한 경우로, 만약 Z22가 3이라면, 약간의 생각으로 Z121역시 3인
것을 알 수 있다. 그러므로 이 설명에서, Z121은 어떠한 문자의 비교도 없이
유추될 수 있다. 이 예는 아래에서 형식적으로 설명되고 증명될 것이다.

  Z-알고리즘

  1<i≤k-1 의 모든 i에 대해, Zi가 주어졌을 때, 그리고 현재의 r과 l값이 주
어졌을 때, Zk와 새로운 r과 l은 다음과 같이 계산한다.

Begin

    1. 만약 k>r이라면, S의 첫 번째 위치와, 그리고 k번째 위치에서, 불일치

      가 발견될 때까지, 명시적(explicit)인 비교를 수행하여 Zk를 찾는다. 매

      치의 길이는 Zk이다. 만약 Zk>0이면, r을 k+Zk-1로 설정하고, l을 k로

      설정한다.

    2. 만약 k≤r이라면, k 위치는 Z-박스에 포함되어 있다. 그러므로 S[k]는

      서브스트링 S[l..r](이것을 α라 하자)에 포함되어 있다. 이 때, l>1이고,

      α는 S의 prefix와 매치한다. 그러므로, 문자 S[k]는 S의

k prime

=k-l+1 위

      치에도 나타난다. 같은 이유로, 서브스트링 S[k..r](이것을 β라 하자)는

      서브스트링 S[

k prime

..Zl]과도 매치한다. 이것은 k에서 시작하는 서브스트링

      S의 prefix와 매치한다는 것이며, 길이는

Z sub {k prime}

과 |β|(r-k+1)중에서 최소

      값 임을 의미한다. <그림 1.3>을 보라.

        최소값에 따라 두 가지 경우를 고려해 볼 수 있다.

      2a.  만약 Zk<|Ղ|이면, Zk=

Z sub {k prime}

이고, r과 l은 변하지 않는다.(<그림 1.4>

          를 보라.)

      2b.  만약 Zk≥|Ղ|이면, 서브스트링 S[k..r]은 반드시 S의 prefix라야 한

          다. 그리고 Zk≥|Ղ|=r-k+1이다. 그러나, Zk가 |Ղ|보다 더 크다. 그러

          므로, S의 r+1위치와 S의 |Ղ|+1위치에서 불일치가 발견될 때까지

          문자 비교를 시작한다. 불일치가 q≥r+1에서 일어났다고 하자. 그

          러면, Zk는 q-k로 설정되며, r은 q-1, l은 k로 설정된다.(<그림

          1.5>를 보라.)

End

             <그림 1.3> 스트링 S[k..r]은 β로 라벨되었다. 그리고

k prime

에도 나타난다.

             <그림 1.4> 2a의 경우,

k prime

에서 시작하면서 S의 prefix와 매치하는 가장 긴
          스트링이 |β|보다 짧다. 이 경우 Zk=

Z sub {k prime}

이다.

             <그림 1.5> 2b의 경우,

k prime

에서 시작하면서 S의 prefix와 매치하는 가장 긴
          스트링의 길이가 최소한 |β|이다.

정리 1.4.1.  Z-알고리즘을 사용하면, Zk는 바르게 계산되며, 변수 r과 l은 바
르게 갱신(update)된다.

증명  1인 경우, Zk는 명시적인 비교에 의해서 결정되므로 정확하다. 또한
k>r이기 때문에, Zk가 계산되기 전, 2와 k-1사이에서 시작하고 k 혹은 이후에
서 끝나는 Z-박스는 발견되지 않았다. 그러므로, Zk>0일 때, 알고리즘은 k 혹
은 이후에서 끝나는 새로운 Z-박스를 찾는다. 그리고 r을 k+Zk-1로 바꾸는 것
은 바르다. 그러므로 알고리즘은 1인 경우 바르게 동작한다.

  2a인 경우, k에서 시작하는 서브스트링은 길이가

Z sub {k prime}

<|Ղ|인 경우만, S의
prefix와 매치된다. 그렇지 않다면, 오른쪽으로 다음 문자, k+

Z sub {k prime}

는, 반드시
1+

Z sub {k prime}

와 매치해야 한다. 그러나,

Z sub {k prime}

<|Ղ|이기 때문에, k+

Z sub {k prime}

는

k prime

+

Z sub {k prime}

와
매치한다. 그러므로,

k prime

+

Z sub {k prime}

은 반드시 1+

Z sub {k prime}

과 매치한다. 그러나, 이것은

Z sub {k prime}

의 정의와 위반(contradiction)된다. 왜냐하면,

k prime

에서 시작하면서 S의
prefix와 매치되는

Z sub {k prime}

보다 긴 서브스트링이 생기기 때문이다. 그러므로 Zk=

Z sub {k prime}

이다. 더군다나, k+Zk-1<r이다, 따라서, r과 l은 변하지 않는다.

  2b인 경우, Ղ는 반드시 S의 prefix이다. 그리고, 이 매치이후의 어떤 확장
(extension)도, prefix Ղ의 끝 이후 문자와 r 이후의 문자를 비교하여, 명시적
으로 검사하기 때문에, 매치의 모든 확장은 바르게 계산된다. 그러므로, Zk는
이 경우에 바르게 계산된다. 더군다나, k+Zk-1≥r이기 때문에, 알고리즘은 r와
l을 바르게 바꾼다. □

보조 정리 1.4.1.  i>2인 모든 위치에 대해 Z 알고리즘을 반복해서 적용하면,
모든 Zi값을 바르게 계산한다.

정리 1.4.2.  모든 Zi(S)값은 알고리즘에 의해서 O(|S|)에 계산된다.

증명  시간은 반복의 횟수 |S|와 문자의 비교 횟수에 비례한다. 각각의 비교
는 일치되거나 불일치이다. 그래서, 일어날 수 있는 일치와 불일치로 한계를
정한다.

  문자 비교를 하는 각각의 반복은 첫 번째 불일치를 발견할 때 모두 종료한
다. 그러므로, 전체 알고리즘 동안 기껏해야 |S|번의 불일치가 있을 수 있다.
매치의 수를 한정짓기 위해, 먼저, 각각의 k번째 반복에서 rk≥rk-1임을 주목하
라. 이제 k를 q>0번의 매치가 일어난 곳의 반복이라 하자. 그렇다면, rk는 적
어도 rk-1+q로 설정된다. 마지막으로, rk≤|S|이다. 그러므로, 알고리즘이 진행되
는 동안 일어나는 전체 매치의 수는 기껏해야 |S|이다. □

  가장 간단한 선형 시간 스트링 매칭
알고리즘

  S=P$T를 패턴 스트링 P와 "$", 그리고 텍스트 스트링 T가 접속
(concatenation)된 스트링이라 하자. 여기서 $는 P와 T의 어디에도 나타나지
않는다. P의 길이가 n이고, T의 길이가 m인 것을 상기하자. 그리고 n≤m이
다. 그래서 S=P$T의 길이는 n+m+1=O(m)이다. 모든 Zi(S)값을 계산한다. 여
기서 i는 1부터 n+m+1까지이다. $는 P와 T의 어디에도 나타나지 않기 때문
에, 모든 i에 대해, Zi≤n이다. Zi(S)=n인 어떤 i>n+1 값도 T에서 P가 i-(n+1)
에 나타남을 의미한다. 반대로 P가 T의 j위치에서 나타난다면, Z(n+1)+j는 반드
시 n이다. 모든 Zi(S) 값이 O(n+m)=O(m)에 계산되기 때문에, T에서 모든 P
를 찾는데, O(m)이 걸린다.

  위의 알고리즘은 알파벳의 크기와는 상관없이 O(n)의 공간만 사용하도록
구현할 수 있다. 모든 i에 대해 Zi≤n이므로, 단계 2에서 결정하는, 위치

k prime

은
항상 P의 안쪽에 나타난다. 그러므로, T상의 모든 문자에 대해 Z값을 기록할
필요는 없다. 대신에, P의 n문자에 대해서만 Z값을 기록하면 된다. 그리고 l과
r값만 유지하면 된다. 그러한 값은 T의 모든 문자에 대해 Z값을 계산하기에
충분하다. 그리고, Zi=n인 위치 i를 출력하면 된다.

  위의 방법이 알파벳에 독립적인(alphabet-independent) 선형 시간 알고
리즘이라는 것을 주목하라. 알파벳에 관한 어떤 정보도 필요하지 않다. 이것은
KMP나 BM알고리즘에서도 사실이지만, 뒷장에서 다룰 suffix 트리를 구축하
는 Ukkonen의 알고리즘에서는 그렇지 않다.

흥미 있는 독자를 위해

  스트링 문제에 흥미가 생긴 독자들은 반드시 KMP나 BM 알고리즘을 이해
하라. 위의 내용은 Dan Gusfield의 1997년 저서인 "Algorithms on Strings,
Trees, and Sequences"의 1장의 내용을 편역한 것이다. 이 책은 스트링 알고
리즘의 훌륭한 교재로 사용될 수 있을 것이다.

  실습문제

1.


18-팔레트 스크롤

18. 팔레트 스크롤
(palette scroll)

  이용되고 있는 곳이 너무나 많기 때문에 팔레트■ 스크롤을 다루지 않을 수

             컴퓨터 그래픽에서 팔레트는 Indirect color map이라 한다. RGB를 위해
          서 픽셀당 16비트 컬러(high color) 이상을 사용하는 시스템에는 팔레트가
          존재하지 않는다. 픽셀당 24비트 컬러(true/full color)를 사용하는 시스템은
          요사이(지금은 1999년 3월) 일반적이다. 독자들이 윈도우 GDI 함수를 사용
          하거나, DirectX의 팔레트 기능을 쓰기 위해서는 화면 모드가 256 컬러 모
          드로 설정(16컬러 모드도 팔레트를 지원하지만 윈도우가 시스템 색(system
          color)의 보호를 위해 팔레트 기능을 제공하지 않는다) 되어야 한다.

없다. 여러분은 게임에서, 멀티미디어 파일을 감상하면서 팔레트 스크롤의 환
상을 경험하고 있다!

  팔레트 스크롤은 색 번호(color index)에 할당된 빛의 삼원색 - red, green,
blue - 을 적절히 조절하여 스크롤 효과를 내는 것을 말한다. 팔레트 조작에
따라 스크롤 효과뿐만 아니라, 겹쳐짐(overlap), 서서히 어두워짐(fade Out),
그림자(shade) 등의 효과를 낼 수 있다. 모든 그래픽 라이브러리는 팔레트를
조작하는 함수를 제공하고 있으며, BGI는 이러한 팔레트 조작을 위해서

setrgbpalette()

setpalette()

등의 함수를 제공한다.

  먼저 색이 화면에 나타나는 원리를 살펴보자. VGA 어댑터(adapter)는 표현
할 수 있는 모든 색에 대해 번호를 할당하여 각 번호마다 RGB값을 가지고
있다. 예를 들어 표준 VGA 어댑터는 한꺼번에 256가지 색을 표현할 수 있으
며 - 320×200모드에서 - , 각 색 번호에 대해 RGB 값을 8비트씩 유지한다.
그러므로 VGA 어댑터 내부에 이러한 값을 레지스터에 유지하는데 이것을
DAC(digital to analog converter) 레지스터라 한다.

index

R

G

B

0

0

0

0

1

100

89

10

...

254

200

32

150

255

255

255

255

<그림 ?> DAC 레지스터

  그러므로 VGA 램의 특정 위치에 255를 넣으면, VGA 어댑터는 DAC의
255번을 참조하여 R,G,B를 꺼낸다. 그리고 이 R,G,B 값으로 CRT(cathod ray
tube)의 전자총의 세기를 조절하여 VGA 램에 대응하는 화면의 위치에 점을
그리는 것이다. 그러므로 VGA 램의 내용은 건드리지 않고 DAC의 RGB값만
바꾸어 주면, 색을 바꾸는 효과를 낼 수 있다.

  640×480 표준 VGA 모드에서는 16색만 지원하며, R,G,B 각각을 위해 6비
트만을 사용할 수 있다. setrgbpalette()는 4개의 파라미터를 필요로 하는데,
첫번째 파라미터는 색 번호를 나머지 3개의 파라미터는 각각 R,G,B 값을 적
어준다. 첫번째 파라미터는 0~15까지 값을 가질 수 있으며, 나머지 3개의 파라
미터는 0~63까지의 값을 가질 수 있다.

  BGI 는 내부적으로 현재 색의 RGB값을 유지하고 있는데, setpalette(i,j)는
BGI가 유지하는 i번 칼라의 RGB를, 실제 VGA 어댑터의 j번째 칼라의 RGB
가 되게 한다. 그러므로 setrgbpalette()에 의해서 직접적인 색이 바뀌는 효과
가 난다.

  그러므로 0번(BLACK) 검은색을 흰색으로 바꾸고 싶으면,

    setrgbpalette(0,63,63,63);

    setpalette(0,0);

이라고 한다. 여러분이 Win95를 부팅할 때 로고화면을 보면 아랫부분에 파란
색의 띠가 옆으로 움직이는 것을 볼 수 있는데, 이것이 바로 팔레트 스크롤의
효과이다. BMP 파일은 파일의 헤더부분에 팔레트 스크롤의 정보를 가지고
있다.

<그림 ?> setpalette(i,j)의 역할

  이와 비슷한 효과를 만들어 보자. 먼저 색의 용도를 나누어야 한다. 16색 중
0~7번을 팔레트 스크롤을 위해 사용하기로 하고, 8~15번은 그림을 그리는데
사용하기로 하자. 우리는 화면에 꽉 차게 위쪽에 8개, 아래쪽에 8개의, 모두
16개의 사각형을 그려서 위쪽 8개의 사각형을 팔레트 스크롤 할 것이다. 게임
등 색을 사용하는 프로그램을 디자인 할 때는 색을 기능별로 잘 구분하여 팔
레트를 셋팅하여 사용한다.

  256색을 지원하는 게임을 만든다고 가정해 보자. 아마도 여러분은 처음 64
색을 전경색(foreground color)으로, 다음 64색을 배경색(background color)으
로, 그 다음 64색을 전경색이 배경에 비치었을 때의 그림자색(shade color)으
로 나머지 64색은 팔레트 스크롤을 위하여 사용할 것이다. 이렇게 색이 잘 구
분되어 게임이 디자인 되었다면, 화면의 일부만 어두워지게 한다든지, 화면
의 일부만 나타나게 한다든지, 비행기의 그림자 등을 배경 위에 그린다든지,
파도가 울렁이는 모습을 팔레트 스크롤을 이용하여 나타낸다든지 하는 일이
무척 간단해 진다. 이 책의 마지막 '프로젝트 만들기' 부분에 이러한 일을 지
원하는 도스용 라이브러리를 제공하므로 이를 참고하기 바란다.

  16색의 16개의 사각형을 그려서 위쪽 8개의 사각형을 팔레트 스크롤하는 예
제를 아래에 리스트하였다. 0~7번색을 스크롤하기 위하여 먼저 그레이 레벨
(gray level)로 색을 정한 다음, 색의 RGB값을 차례대로 회전(rotate)하여 스
크롤 효과를 내고 있다.

<그림 ?> 팔레트 스크롤

#include <stdio.h>

#include <graphics.h>

#include <conio.h>

#include <stdlib.h>

int i,j,pal[8][3];

void DrawLogo(void)

{

    for (i=0;i<8;++i) {

        setfillstyle(SOLID_FILL,i);

        bar(i*80,0,i*80+80,239);

        setfillstyle(SOLID_FILL,i+8);

        bar(i*80,240,i*80+80,479);

    }

}

void InitPal(void)

{

    for (i=0;i<8;++i) {

        pal[i][0]=i*8;

        pal[i][1]=i*8;

        pal[i][2]=i*8;

    }

}

void SetPal(void)

{

    for (i=0;i<8;++i) {

        setrgbpalette(i,pal[i][0],pal[i][1],pal[i][2]);

        setpalette(i,i);

    }

}

void RotatePal(void)

{

    int old[3];

    for (i=0;i<3;++i) old[i]=pal[0][i];

    for (i=0;i<=6;++i) {

        pal[i][0]=pal[i+1][0];

        pal[i][1]=pal[i+1][1];

        pal[i][2]=pal[i+1][2];

    }

    for (i=0;i<3;++i) pal[7][i]=old[i];

}

void main(void)

{

    int gd=DETECT,gm;

    initgraph(&gd,&gm,"");

    DrawLogo();

    InitPal();

    SetPal();

    while (1) {

        RotatePal();

        SetPal();

        if (kbhit()) break;

    }

    closegraph();

}

그림자 효과(shade effect)

<그림 ?> 그림자 효과의 실행화면

#include <stdio.h>

#include <conio.h>

#include <graphics.h>

#define SHADE_OFFSET 15

int x1=100,y1=100,x2=120,y2=120,dx1=1,dy1=1,dx2=1,dy2=1,p=0;

void OpenGraphEga(void)

{

    int gd=VGA,gm=VGAMED;

    initgraph(&gd,&gm,"..\\..\\bgi");

    settextstyle(DEFAULT_FONT,HORIZ_DIR,3);

}

void SetMyPalette(void)

{

    char RGB[16][3]={{ 8,10, 8},

                     {26,24,39},

                     {21,34,15},

                     {31,42,36},

                     {63,35,30},

                     {39,46,54},

                     {20,21,16},

                     {48,40,27},//

                     { 0, 0, 0},

                     {16,14,29},

                     {11,24, 5},

                     {21,32,26},

                     {53,25,20},

                     {29,36,44},

                     {10,11, 6},

                     {38,30,17}};

    int i;

    for (i=0; i < 16; i++) {

        setrgbpalette(i, RGB[i][0], RGB[i][1], RGB[i][2]);

        setpalette(i,i);

    }//for

}//SetMyPalette

void DrawBack(void)

{

    int i;

    for (i=0;i<8;++i) {

        Rect2(i*80,0,i*80+79,116,RECT_1,i,SOLID_FILL);

        Rect2(i*80,117,i*80+79,232,RECT_1,(i+1)%8,SOLID_FILL);

        Rect2(i*80,233,i*80+79,349,RECT_1,(i+2)%8,SOLID_FILL);

    }

    setcolor(BLACK);

    outtextxy(170,235,"Mystic Screen Saver");

    setcolor(GREEN-1);

    outtextxy(166,231,"Mystic Screen Saver");

}

void DrawShade(int x1,int y1,int x2,int y2)

{

    int x,y;

    for (x=x1;x<=x2;++x)

        for (y=y1;y<=y2;++y)

            putpixel(x,y,getpixel(x,y)+8);

}

void DrawObject(int x1,int y1,int x2,int y2)

{

    DrawShade(x1+SHADE_OFFSET,y1+SHADE_OFFSET,

              x2+SHADE_OFFSET,y2+SHADE_OFFSET);

    Rect2(x1,y1,x2,y2,RECT_2,WHITE,SOLID_FILL);

}

void main(void)

{

    OpenGraphEga();

    SetMyPalette();

    while (TRUE) {

        //Rect2(0,0,639,349,RECT_1,BLACK,SOLID_FILL);

        setactivepage(p^1);

        setvisualpage(p);

        if (kbhit()) break;

        if (x1<=0) dx1=-dx1, dx2=-dx2;

        if (x2>=639) dx2=-dx2, dx1=-dx1;

        if (y1<=0) dy1=-dy1, dy2=-dy2;

        if (y2>=349) dy2=-dy2, dy1=-dy1;

        x1+=dx1, y1+=dy1;

        x2+=dx2, y2+=dy2;

        DrawBack();

        DrawObject(x1,y1,x2,y2);

        p^=1;

    }

    closegraph();

}

겹쳐짐(overlap)

<그림 ?> 첫번째 그림

<그림 ?> 두번째 그림

<그림 ?> 실행중인 겹쳐짐 프로그램

#include <stdio.h>

#include <graphics.h>

#include <conio.h>

#include <stdlib.h>

#define DEBUG

void SetBlack(void)

{

    int i;

    for (i=0;i<getmaxcolor();++i) {

        setrgbpalette(i,0,0,0);

        setpalette(i,i);

    }

}

void DrawLogo(void)

{

    char *buf;

    settextstyle(DEFAULT_FONT,HORIZ_DIR,10);

    setcolor(3);

    setfillstyle(INTERLEAVE_FILL,3);

    fillellipse(200,200,100,100);

    outtextxy(50,200,"FIRST");

    buf=(char *)malloc(imagesize(50,200,50+5*80,200+80));

    getimage(50,200,50+5*80,200+80,buf);

    setcolor(12);

    setfillstyle(INTERLEAVE_FILL,12);

    fillellipse(450,200,100,100);

    outtextxy(50,200,"SECOND");

    putimage(50,200,buf,OR_PUT);

    free(buf);

}

void FigureIn(void)

{

    int i;

    for (i=0;i<64;++i) {

        setrgbpalette(12,i,i,i);

        setpalette(12,12);

        setrgbpalette(15,i,i,i);

        setpalette(15,15);

    }

}

void FigureOut(void)

{

    int i;

    for(i=63;i>=0;--i) {

        setrgbpalette(3,i,i,i);

        setpalette(3,3);

        setrgbpalette(15,i,i,i);

        setpalette(15,15);

    }

}

void Overlap(void)

{

    int i=0;

    while (i<64) {

        setrgbpalette(3,i,i,i);

        setpalette(3,3);

        setrgbpalette(12,63-i,63-i,63-i);

        setpalette(12,12);

        if (i<=32)

            setrgbpalette(15,63-i,63-i,63-i);

        else

            setrgbpalette(15,i,i,i);

        setpalette(15,15);

        ++i;

      #ifdef DEBUG

        if (i==1 || i==63 || i==22) getch();

      #endif

    }

}

void main(void)

{

    int gd=DETECT,gm;

    initgraph(&gd,&gm,"..\\..\\bgi");

    SetBlack();

    DrawLogo();

    FigureIn();

    Overlap();

    FigureOut();

    closegraph();

}

실습문제

1. 위에서 제시한 16색의 팔레트 스크롤은 클래스를 사용하지 않고 있다. 팔레
트 스크롤에 뿐만 아니라 일반적으로 이용 가능한 팔레트 스크롤 클래스를 작
성하여, 위 프로그램을 수정하라.

2. 위에서 제시한 그림자 효과 프로그램에 범용의 그림자 효과를 내는 클래스
를 작성하여, 프로그램을 수정하라.

3. 위에서 제시한 겹쳐짐 효과 프로그램에 범용의 겹쳐짐 효과를 내는 클래스
를 작성하여, 겹쳐짐 애니메이션 프로그램을 작성하라.

4.


19. 페이징(Paging)

19. 이중 버퍼링
(Double Buffering)

  이 장의 주제는 컴퓨터 애니메이션에 관한 것이다. 페이징에 관한 자
세한 사항은 관련서적을 참고하기 바라며, 이 장에서는 개념과 간단한
예제만을 제시할 것이다. 관심이 없는 독자는 이 장을 건너뛰어도 좋다.
왜냐하면, 페이징은 애니메이션 외에는 사용되는 곳이 거의 없기 때문
이다.

  컴퓨터 애니메이션은 전경(Foreground)과 배경(Background)을 반복
해서 그리는 작업이다. 요사이의 컴파일러는 라이브러리 레벨에서 이러
한 애니메이션을 위한 함수들을 제공한다. 비록 이러한 함수들이 제공
되고 있기는 하지만, 개념을 이해하는 것은 중요하며, 또한 여러분만의
페이징을 위해서 이러한 함수들을 직접 만들 필요성도 생긴다.

  일반적으로 표준 그래픽 라이브러리는 페이징을 위해 그래픽 페이지
를 지원한다. 또한 어떤 하드웨어는 하드웨어 레벨에서 페이지를 지원
한다. 하지만 이러한 페이지가 하드웨어에 의해서 지원되지 않는 경우,
소프트웨어적으로 가상적인 페이지를 만들어서 필요한 함수들을 구현하
는데, 이러한 경우를 가상 페이징(Virtual Paging)이라 한다. 가상 페
이징은 화면 출력용 버퍼가 2개라는 의미에서 이중 버퍼링(Double
Buffering)이라는 말로도 사용된다.

  먼저 페이지가 지원되지 않는 - 페이지가 1개뿐인 - 시스템에서 애니
메이션을 구현할 때 생기는 문제점에 대해서 살펴보자.

  우리가 원하는 작업은 아래와 같은 배경 그림 위로 우주선이 움직이
도록 하는 것이다.

              배경(Background)

  우주선의 초기 위치가 <그림 ?>와 같이 주어졌을 때, 우주선을 <그
림 ?> 처럼 움직여서, 최종적으로 <그림 ?>와 같은 상태를 얻고자 한
다.

   우주선의 초기 위치        우주선의 움직임

             우주선의 최종 위치

  그러면 여러분은 다음과 같은 프로그램 문장들을 작성할 것이다.

  (1) 우주선의 배경을 원래의 우주선 위치에 다시 그려서 배경 이미지
를 회복한다.

  (2) 새로운 우주선의 위치를 계산하고, 배경 회복을 위해 우주선이
그려질 배경 이미지를 저장한다.

  (3) 새로운 위치에 우주선을 그린다.

  (4) 단계 (1)로 간다.

  이 프로그램의 어디가 문제인가? 바로 밑줄 친 그리는(Draw) 부분이
문제가 된다. 컴퓨터 화면에 배경을 그리는 과정이 보이고, 우주선을 그
리는 과정이 보이게 된다. 아무리 그리는 함수를 빠르게 작성한다고 하
더라도 이미지에 약간의 떨림(Flickering)이 느껴진다. 그려야 하는 전
경이 많다면 컴퓨터 화면은 심하게 떨리게 될 것이다. 그렇다면 어떻게
떨림을 없앨 것인가?

  그리는 과정은 사용자가 볼 수 없어야 한다. 이미지를 다 그린 후의
상태만을 본다면 사용자는 떨림을 느낄 수가 없다. 즉 이미지를 그리는
화판 2개를 준비해서 이미 그려진 그림을 사용자에게 보여주고, 다음
장면은 사용자가 보지 못하는 숨겨진 화판에 그린다. 그림이 다 완성되
면, 이 숨겨진 화판을 사용자에게 보여주는 것이다. 이 과정을 화판을
바꾸어 가면 반복하면, 사용자는 떨림이 없는 매끄러운 애니메이션을
볼 수 있다. 화판을 컴퓨터에서는 페이지(Page)라고 한다. 그러므로 컴
퓨터 하드웨어는 부드러운 애니메이션을 위해서 2개 이상의 페이지를
지원해야 한다. 그리고 그래픽 라이브러리의 그리기 함수는 지정된 페
이지 - 원도우에서는 디바이스 컨텍스트(DC)를 이용하여 페이징을 한
다 - 에 그릴 수 있는 다양한 함수를 제공해야 한다.

  표준 VGA 모드 중 640×350, 16컬러 모드는 2개의 페이지 - 각각 0
번과 1번 페이지 - 를 지원한다. 그러므로 BGI 라이브러리 역시 페이징
을 위한 함수들을 제공한다. 320×200, 256컬러 모드에서는 하드웨어에
의한 페이지가 지원되지 않으므로, 페이징 관련 함수 및 그리기 함수를
직접 만들어서 가상적인(Virtual) 페이지를 만들어서 사용해야 한다.
BGI 함수 중 그리는 페이지와 보이는 페이지를 지정하는 함수는 각각
다음과 같다.

setactivepage(<page number>);

setvisualpage(<page number>);

  아래의 코드는 보이는 페이지가 1번인 상태에서 0번에 직선을 그리므
로, 사용자는 그리는 과정을 볼 수 없다.

    setactivepage(0);

    setvisualpage(1);

    line(0,0,100,100);

이 과정 후에 다 그려진 0번 페이지를 화면에 나타나게 하려면, 마지막
에 한 줄을 추가한다.

    setactivepage(0);

    setvisualpage(1);

    line(0,0,100,100);

    setvisualpage(0);

사용자는 그리는 과정을 보지 못했으므로 전혀 떨림을 느낄 수가 없다.
여러분은 페이징을 사용한 것이다!

  벌레(Bug)

  우리는 화면을 돌아다니는 그럴듯한(?) 벌레(Bug)를 만들 것이다. 또
한 다른 장과는 달리, 이 문제를 풀기 위한 클래스 설계 등을 자세히
설명할 것이다. 여러분은 이 장에서, 주어진 문제를 풀이하는 과정을 통
해서 클래스 설계 능력을 키울 수 있을 것이다.

  먼저 벌레를 분석해 보자. 이러한 문제에 대한 조심스러운 분석은 프
로그램의 설계와 직접 연관이 된다. 아래 <그림 ?>는 우리가 설계할
벌레이다.

             몸통(Knob)을 7개 가지는 벌레

  벌레는 몸통을 한쪽 끝에서 꾸부려, 다른 끝으로 꾸부린 몸통을 전파
하면서, 전진을 한다. 위의 <그림 ?>에서 굵은 원은 지금 꾸부려진 몸
통을 나타낸다. 그러므로 벌레는 굵은 몸통의 위치에 따라 0에서 6까지
7개의 상태를 가질 수 있다. 이것은 오토마타로 설계한다.

  벌레는 한 번 움직이기 위해서 상태 0에서 시작한다.

             상태 0의 벌레의 모양

상태는 0에서 6까지 천이(Transition)하며, 이 때 벌레는 움직이지는 않
고 몸통만을 꾸부릴 뿐이다. 마침내 상태 6이 되었을 때, 벌레는 한 번
움직인다.

             상태 6의 벌레의 모양: 이 때 한 번 움직여야 한다

  이제 벌레의 몸통에만 집착해 보자. 벌레의 몸통은 작은 상태 0과 큰
상태 1을 가진다. 그렇다면 우리는 벌레의 몸통을 위해서 CKnob란 클
래스를 설계해야 할 것이다. 또한 이러한 CKnob 객체를 여러개 가지는
(HAVE-A) CBug 클래스를 설계해야 할 것이다.

  몸통들은 모두 움직이므로, 움직이는 특성을 CMove로 설계해서
CKnob는 이를 상속받으면 된다. 또한 범용적으로 사용되는 CPaging을
설계■해서 CBug는 이를 상속받아 효과적으로 페이징을 구현할 수 있

             일반적으로 언어의 라이브러리는 이러한 범용 페이징 클래스를 지
          원한다. 여러분이 해 주어야 할 일은 전경을 그리고, 배경을 그리는
          작업을 자신에 맞게 추가하는 일 뿐이다.

다.

  먼저 CMove를 설계해 보자.

class CMove {

    int cx,cy;

  public:

    void MoveTo(int x,int y) { cx=x; cy=y; }

    void StepTo(int dx,int dy) { cx+=dx; cy+=dy; }

    int GetX() { return cx; }

    int GetY() { return cy; }

};//class CMove

  CMove는 현재의 위치를 (cx,cy)에 유지한다. 절대 좌표로 움직이는
MoveTo()와 상대적으로 움직이는 StepTo()를 멤버함수로 가지며, 현재
의 위치를 읽어오는 GetX()와 GetY()를 가진다.

  페이징을 위한 클래스는 어떻게 작성할 것인가? 우리는 페이징을 위
한 멤버 함수는 구현할 수 있다. 하지만, 전경과 배경을 그리는 루틴은
적용마다 달라지므로 구체적인 구현은 할 수 없다. 하지만 반드시 구현
해야 한다. 이처럼 반드시 필요하지만 구체적인 구현은 사용자에게 맡
겨진 부분은 순수 가상 함수(Pure Virtual Function)로 구현한다. 이
렇게 순수 가상 함수를 1개 이상 멤버 함수로 가진 클래스는 객체를 생
성시킬 수가 없으므로 추상 클래스(Abstract Class) 혹은 가상 클래
스(Virtual Class)라 한다.

class CPaging {

    int page;

  public:

    CPaging() { page=0; }

    void Paging() { setvisualpage(page); setactivepage(page^=1); }

    virtual void Draw() = 0;//순수 가상 함수 임을 나타낸다

    virtual void DrawBackground() = 0;

};//class CPaging

  완성된 클래스는 <그림 ?>와 같은 구조를 지니게 될 것이다. CKnob
는 CMove를 상속받아 구현하며, CBug는 CPaging을 상속받아 구현한
다.

             벌레의 클래스 계층도

class CKnob : public CMove {

    int state;//0:small knob, 1:large knob

  public:

    void Draw();

    void SetState(int s) { state=s; }

    int GetState() { return state; }

};//class CKnob

  CKnob는 몸통의 상태를 유지하기 위해 state를 멤버 변수로 가지며,
상태에 따라 현재 위치 (cx,cy)에 몸통을 그리는 Draw()와, 상태를 얻어
오고, 정하는 GetState()와 SetState()를 멤버 함수로 가진다.

class CBug : public CPaging {

    CKnob k[MAXKNOB];

    int state,

        dx,dy;

  public:

    CBug(int x=0,int y=0);

    void Draw();

    void DrawBackground();

    void Move();

    void DrawAll() { Paging(); DrawBackground(); Draw(); Move(); }

};//class CBug

  CBug는 CKnob의 객체 배열 k[]를 가진다. CBug에서 벌레의 상태변
화는 다음과 같이 구현한다.

void CBug::Draw()

{

    for (int i=0;i<MAXKNOB;++i) {

        k[i].SetState(0);

        k[i].Draw();

    }//for

    k[state].SetState(1);

    k[state].Draw();

}//CBug::Draw

  벌레는 상태 유지를 위해서 state, 움직이는 간격을 dx, dy에 유지한
다. 벌레가 화면의 가장 자리에 닿았을 때 튕기는 것은 '?. XOR 애니메
이션'을 참고하기 바란다. 이 부분은 Move()가 담당한다.

  프로그램의 실행화면과 소스를 아래에 리스트하였다.

             벌레의 실행 화면

#include <stdio.h>

#include <conio.h>

#include <dos.h>

#include <graphics.h>

/*-------------------constants definition-------------------*/

const int MAXKNOB=7;

const int STEP=10;

// CPaging
class----------------------------------------------------------

class CPaging {

    int page;

  public:

    CPaging() { page=0; }

    void Paging() { setvisualpage(page); setactivepage(page^=1); }

    virtual void Draw() = 0;

    virtual void DrawBackground() = 0;

};//class CPaging

//CMove
class-------------------------------------------------------------
--

class CMove {

    int cx,cy;

  public:

    void MoveTo(int x,int y) { cx=x; cy=y; }

    void StepTo(int dx,int dy) { cx+=dx; cy+=dy; }

    int GetX() { return cx; }

    int GetY() { return cy; }

};//class CMove

//CKnob
class-------------------------------------------------------

class CKnob : public CMove {

    int state;//0:small knob, 1:large knob

  public:

    void Draw();

    void SetState(int s) { state=s; }

    int GetState() { return state; }

};//class CKnob

void CKnob::Draw()

{

    int width=(GetState()==0 ? STEP-1 : STEP+1);

    setfillstyle(SOLID_FILL,WHITE);

    setcolor(WHITE);

    fillellipse(GetX(),GetY(),width,width);

}//CKnob::Draw

//CBug
class--------------------------------------------------------

class CBug : public CPaging {

    CKnob k[MAXKNOB];

    int state,

        dx,dy;

  public:

    CBug(int x=0,int y=0);

    void Draw();

    void DrawBackground();

    void Move();

    void DrawAll() { Paging(); DrawBackground(); Draw(); Move(); }

};//class CBug

CBug::CBug(int x,int y)

{

    for (int i=0;i<MAXKNOB;++i) {

        k[i].MoveTo(x,y);

        x+=STEP; y+=STEP;

    }//for

    dx=STEP; dy=STEP; state=0;

}//CBug::CBug

void CBug::Draw()

{

    for (int i=0;i<MAXKNOB;++i) {

        k[i].SetState(0);

        k[i].Draw();

    }//for

    k[state].SetState(1);

    k[state].Draw();

}//CBug::Draw

void CBug::Move()

{

    state=(state+1)%MAXKNOB;

    if (state!=0) return;

    if (k[MAXKNOB-1].GetX() <= 0 || k[MAXKNOB-1].GetX() >= 639)

        dx=-dx;

    if (k[MAXKNOB-1].GetY() <= 0 || k[MAXKNOB-1].GetY() >= 349)

        dy=-dy;

    for (int i=0;i<MAXKNOB-1;++i)//move knobs

        k[i]=k[i+1];

    k[MAXKNOB-1].StepTo(dx,dy);//set head

}//CBug::move

void CBug::DrawBackground()

{

    setfillstyle(CLOSE_DOT_FILL,GREEN);

    bar(0,0,639,349);

    setcolor(LIGHTGREEN);

}//CBug::DrawBackground

void main()

{

    int gd=VGA,gm=VGAMED;

    initgraph(&gd,&gm,"..\\bgi");

    CBug b(0,0);

    b.Draw();

    while (!kbhit())

        b.DrawAll();

    closegraph();

}//main

  320×200 라이브러리

  지금이 320×200, 256컬러 표준 VGA용 라이브러리를 소개할 적절한
시기인 것 같다. 이 라이브러리를 완전히 이해할 필요는 없다. 다만 아
래의 연습문제를 풀기 위해서 이 라이브러리를 사용해야 한다. 또한 여
러분은 이 라이브러리에서 제시된 것과 비슷하거나 같은 함수를 다른
그래픽 라이브러리에서 발견할 수 있을 것이다.

  이 라이브러리는 DOS용으로 만들어진 것이므로 효용성은 거의 없다.
하지만, 프로그래밍 과목을 듣는 학생에게 '설계하는 법'을 가르치는 훌
륭한 예제가 될 것이다. 또한 이 책의 마지막 장에서 종합적인 예제를
몇 개 만들게 될 때 우리는 이 라이브러리를 사용할 것이다.

#include <dir.h>

/* macros */

#ifndef APAGE

#define APAGE               0     /* Actual Screen */

#define PAGE0               0

#define VPAGE               1     /* Virtual Screen */

#define PAGE1               1

#define SCREEN_WIDTH        320   /* 320*200 screen resolution */

#define SCREEN_HEIGHT       200

#define GETMAXX             319   /* max
coordinate(=SCREEN_WIDTH-1) */

#define GETMAXY             199   /* =SCREEN_HEIGHT-1 */

#define MAXCOLOR            256   /* number of colors */

#define GETMAXCOLOR         255   /* max color(=MAXCOLOR-1) */

#define VIRTUAL_SIZE        64000 /* 320*200 virtual page size */

#define HVIRTUAL_SIZE       32000 /* 64000 / 2 half virtual page
size */

#define BUF_SIZE            1024  /* pcx working buffer size */

#define PCXHEAD_SIZE        128   /* pcx header */

#define PAL_SIZE            768   /* 256*3(r,g,b) */

/* macro functions */

#define HIBYTE(w)           ((unsigned char)(((int)(w)>>8) &
0xff))

#define LOBYTE(w)           ((unsigned char)(w))

#define TOGGLE_PAGE(p)      ((p)^1)

#define _PUT_PIXEL(ofs,c)   (*(graphbase+(ofs))=(c)) /* macros are
fast! */

#define _GET_PIXEL(ofs)     (*(graphbase+(ofs)))

#define PutPixel(x,y,c)
(*(graphbase+(y)*SCREEN_WIDTH+(x))=(c))

#define GetPixel(x,y)       (*(graphbase+(y)*SCREEN_WIDTH+(x)))

#define ClearPage()         (_fmemset(graphbase,0,VIRTUAL_SIZE))

#define ImageSize(x1,y1,x2,y2)
((UINT)(((x2)-(x1)+1)*((y2)-(y1)+1)+4))

#define wait()              if (getch()==0) getch()

//keyboard interrupt macros

#define MAX_KKEY            88

#endif

/* user defined data types */

typedef enum {

            FALSE,   /* 0 */

            TRUE     /* 1 */

        }   BOOL;

typedef unsigned char       BYTE;

typedef unsigned int        UINT;

typedef unsigned int        WORD;

typedef unsigned long int   ULONG;

enum { /* used in PutImage() */

    COPY_PUT, /* 0 */

    XOR_PUT, /* 1 */

    OR_PUT,  /* 2 */

    AND_PUT, /* 3 */

    NOT_PUT, /* 4 */

    MASK_PUT /* 5 */

};

enum { /* used in Overlap() */

    OVERLAP,    /* 0 */

    PREHISTORIC /* 1 */

};

enum { /* used in Adjust_16() */

    LOW16=0,

    HIGH16=4

};

//keyboard interrupt constants

enum {

    OFF=0,

    ON=1

};

enum {

      K_ESC=0x1,

      K_ONE=0x2,

      K_TWO=0x3,

      K_THREE=0x4,

      K_FOUR=0x5,

      K_FIVE=0x6,

      K_SIX=0x7,

      K_SEVEN=0x8,

      K_EIGHT=0x9,

      K_NINE=0xa,

      K_ZERO=0xb,

      K_UNDERBAR=0xc,

      K_EQUAL=0xd,

      K_BACKSPACE=0xe,

      K_TAB=0xf,

      K_Q=0x10,

      K_W=0x11,

      K_E=0x12,

      K_R=0x13,

      K_T=0x14,

      K_Y=0x15,

      K_U=0x16,

      K_I=0x17,

      K_O=0x18,

      K_P=0x19,

      K_OPEN=0x1a,

      K_CLOSE=0x1b,

      K_ENTER=0x1c,

      K_CTRL=0x1d,

      K_A=0x1e,

      K_S=0x1f,

      K_D=0x20,

      K_F=0x21,

      K_G=0x22,

      K_H=0x23,

      K_J=0x24,

      K_K=0x25,

      K_L=0x26,

      K_SEMICOLON=0x27,

      K_QUOTATION=0x28,

      K_FROM=0x29,

      K_LSHIFT=0x2a,

      K_BACKSLASH=0x2b,

      K_Z=0x2c,

      K_X=0x2d,

      K_C=0x2e,

      K_V=0x2f,

      K_B=0x30,

      K_N=0x31,

      K_M=0x32,

      K_COMMA=0x33,

      K_PERIOD=0x34,

      K_SLASH=0x35,

      K_RSHIFT=0x36,

      K_PRTSC=0x37,

      K_ALT=0x38,

      K_SPACE=0x39,

      K_CAPSLOCK=0x3a,

      K_F1=0x3b,

      K_F2=0x3c,

      K_F3=0x3d,

      K_F4=0x3e,

      K_F5=0x3f,

      K_F6=0x40,

      K_F7=0x41,

      K_F8=0x42,

      K_F9=0x43,

      K_F0=0x44,

      K_NUMLOCK=0x45,

      K_SCROLLLOCK=0x46,

      K_HONE=0x47,

      K_UP=0x48,

      K_PGUP=0x49,

      K_MINUS=0x4a,

      K_LEFT=0x4b,

      K_CENTER=0x4c,

      K_RIGHT=0x4d,

      K_PLUS=0x4e,

      K_END=0x4f,

      K_DOWN=0x50,

      K_PGDN=0x51,

      K_INS=0x52,

      K_DEL=0x53,

      K_F11=0xd9,

      K_F12=0xda};

#ifdef __cplusplus

extern "C" {

#endif

/* Vga output global variables */

extern BYTE huge *graphbase;/* current working page */

                    /* graphbase initialized page 0 */

extern int curpage;     /* current working page */

                    /* curpage initialized page 0 */

extern UINT workingpage;/* base address of working page */

                    /* workingpage initialized page 0 */

extern BYTE palinfo[PAL_SIZE];/* recently readed pcx palette info.
*/

extern int machine_delay;/* machine speed factor */

/* Keyboard interrupt global variables */

extern BYTE kkey[MAX_KKEY];

/* Ascii output global variables */

extern BYTE nTextColor,nTextWidth,nTextHeight;

//Vga output function declarations

extern void _delay(long d);

extern void sync(void);

/* vertical retrace synchronization

   0x3da : CRT status register address */

extern BOOL IsVga(void);

/* return TRUE when VGA is installed */

extern BOOL Open256(void);

/* Open 320*200*256 graphic mode */

extern void Close256(void);

/* close graphic mode */

extern void FreeVirtual(void);

/* free virtual page */

extern void ExitToDos(char *msg);

/* exit to DOS */

extern void GetVirtual(void);

/* initialize virtual page */

extern void SetPage(int page);

/* set active working page, visual page is always APAGE */

extern int GetPage(void);

/* return current working page */

extern void _ClearPage(int page);

/* clear page */

extern void PageShift(int bits);

extern void PageMask(BYTE mask);

extern void SetRGBPalette(int color,int r,int g,int b);

/* Set palette */

extern void GetRGBPalette(int color, BYTE *r,BYTE *g,BYTE *b);

/* Get palette r,g,b values */

extern void SetPalette256(BYTE buf[]);

/* set palette with buf */

extern void GetPalette256(void);

/* read palette information to palinfo[] */

extern void SetPalette16(BYTE buf[]);

/* set palette with buf */

extern void PageRestore(void);

/* virtual(=1) to actual(=0) page copy

   DS:SI컴>ES:DI, secondpage:0 컴> firstpage:0 */

extern void PageBackup(void);

/* actual(=0) to virtual(=1) page copy

   page 0 컴> page 1*/

extern void SetPageWidth(BYTE size);

/* hardware scroll routine */

extern void SetPageStart(int addr);

/* hardware scroll routine */

extern void Move(BYTE *src,BYTE *dest,UINT size);

/* The fastest scroll routine

   seg_s:ofs_s 컴> seg_d:ofs_d */

extern void GetImage(int x1,int y1,int x2,int y2,BYTE *bitmap);

/* get images in working screen */

extern void PutImageNormalPut(int x,int y,BYTE *bitmap);

/* draw images in bitmap with COPY_PUT */

extern void PutImageMaskPut(int x,int y,BYTE *bitmap);

extern void PutImageMaskZoom(long x,long y,BYTE *bitmap,long
dxsize,long dysize);

/* Put masked and zoomed bitmap image

   dxsize,dysize : zoomed image size

   þ This routine must be converted with assembly */

extern void PutImage(long x,long y,BYTE *bitmap,int bitblt);

extern void ReverseImage(int x1,int y1,int x2,int y2);

/* Invert working screen image */

extern void AsmPutPixel(int x,int y,BYTE color);

/* assembler put a pixel routine */

extern void SetBlackPaletteWithRange(int to,int from);

/* Set all 256 colors to black */

extern void SetBlackPalette(void);

extern void FigureInWithRange(int to,int from,UINT d);

/* Set palinfo[] colors slowly */

extern void FigureIn(void);

/* Set palinfo[] colors slowly */

extern void FadeOutWithRange(int from,int to,UINT d);

/* set palinfo[] to black colors slowly */

extern void FadeOut(void);

/* set palinfo[] to black colors slowly */

extern void SetRGBGrayPaletteWithRange(int r,int g,int b,int
to,int from);

/* for smooth gray level

   0.3, 0.59 and 0.11 are multiplied for each R,G and B */

extern void SetRGBGrayPalette(int r,int g,int b);

extern void Overlap(UINT d,int method);

/* First show 0xf0 colors and slowly change to 0x0f colors

   d : delay

   method : 0 - Overlap

            1 - PreHistoric 2 */

extern int SaveTextScreen(char fn[MAXPATH]);

/* Save VGA text screen to file fn[] */

extern int LoadTextScreen(char fn[MAXPATH]);

/* restore VGA text screen from file fn[] */

extern void AdjustPal(BYTE palbak[]);

/* A screen image which has palbak[] palette

   is adjusted for optimal palinfo[] palette

   The image will be modified */

extern int LoadPcx256(char filename[MAXPATH],BOOL palset);

/* load only 320*200*256 pcx file

   I first used 'fopen()' and 'fread()' to load pcx file.

   And I meet a serious problem in load pcx file.

   I wondered about a week and gived up..

   I decided change the function.

   So I used '_open()' and '_read()'.

   The Change was successful!

   I fixed my heart so that I shall never use 'fopen()' etc.

   On 10/21/1994

   */

extern void PcxWFile(int handle,BYTE buf[],int *bufwi);

/* pcx file write routine */

extern void PcxWBuf(int handle,BYTE buf[],int *bufwi,BYTE
dotmem,int dotn);

/* bufwi : index of buffers

   dotmem : data byte

   dotn : number of repeatition */

extern void Init320x200x256PcxHead(BYTE buf[]);

/* init 320*200*256 pcx head */

extern int SavePcx256(char filename[MAXPATH]);

/* save working page to 320*200*256 pcx file */

extern void Adjust_16(UINT sc);

/* convert 256 color image to 16 color image

   sc : shift count(0 or 4)

        0 : lower 16 colors are used

        4 : higher 16 colors are used */

//Keyboard interrupt function delcarations

extern void changekeyboardinterrupt(void);

extern void restorekeyboardinterrupt(void);

extern BYTE newkbhit(void);

extern void clearkey(void);

extern int newgetch(void);

//Ascii output functions

int LoadEngFont(char FontFile[]);

/* Read ASCII 8x16, 256 bitmapped font

   Return 1 when fone file is successfully loaded */

extern void SetTextColor(BYTE c);

extern void SetTextWidthHeight(BYTE nWidth,BYTE nHeight);

extern void PutAChar(int x,int y,int c);

extern void OutEngXY(int x,int y,char s[]);

#ifdef __cplusplus

}

#endif

  실습문제

1. 우주선 그림과 배경 그림이 책에서 제공하는 디스켓에 들어 있다. 우
주선이 화면을 돌아다니는 프로그램을 작성하라. 단, 화면의 가장자리에
서 우주선은 튕기며 사용자가 키보드로 우주선의 움직이는 방향을 조정
할 수 있어야 한다. 또한 우주선은 진행 방향에 따라, 적절하게 이미지
를 토글하여 움직이고 있는 것 처럼 느껴져야 한다.

             우주선

우주선이 왼쪽으로 움직일 때는 ①과 ③을 토글한다. 오른쪽으로 움직
일 때는 ①과 ④를 토글하며, 앞이나 뒤로 움직일 때는, ①과 ②를 토글
한다.

2. 테트리스 게임을 작성하라. 단 애니메이션에 반드시 더블 버퍼링을
사용하여야 한다.

3. 벽돌 격파 게임을 작성하라. 단 애니메이션에 반드시 더블 버퍼링을
사용하여야 한다.

4. 움직이는 GIF 파일을 읽어들여 보여주는 프로그램을 작성하라. 단
더블 버퍼링을 사용하여야 한다.


31. 수직적인 사고 vs. 수평적인 사고(vertical reasoning vs. horizont

31. 수직적인 사고 vs. 수
평적인 사고(vertical
reasoning vs. horizontal
reasoning)

  프레임(frame)

  경북대학교 컴퓨터공학과 C++수업 시간이다. 갑자가 자동차가 위험을 경고
하며, 심하게 경적을 울리는 소리가 학생들의 뒤편 교실 끝에서 들려왔다. 하
지만, 학생들은 어느 누구도 몸을 날려 자동차를 피하려 하지 않는다. 왜 그런
가? 학생들은 누구나 교실 안에서는 자동차의 경적 소리가 자신의 뒤에 자동
차가 있다는 것을 의미하지 않는다는 것을 알고 있다.

  늦은 밤 C++ 수업을 들은 한 학생이 도로를 무단 횡단중이다. 좌측에서 자
동차의 경적 소리가 들린다. 학생은 위험을 느끼며, 경적 소리가 나는 쪽을 쳐
다본다. 왜 그런가? 도로라는 상황에서는 자동차의 경적은 자동차가 가까이
있음을 의미한다.

  위의 얘기는 1997년 대학원의 인공지능 수업 시간에 교수님으로부터 들은
이야기이다. 인공 지능 용어 중에 프레임(frame)이라는 말이 있다.

  수직적인 사고

  수평적인 사고

  매트릭스 곱을 구하는 Strassen's matrix multiplication 알고리즘, 스트링
매칭 알고리즘인 BM string matching 알고리즘과 Rabin-Karp string
matching 알고리즘은 우리가 쉽게 접할 수 있는 알고리즘 중, 수평적인 사고
를 한 예이다.

  연습문제

1.


33. Suffix 트리(Tree)

  독자들은 이 장에서 금세기 최고의 알고리즘인 Suffix 트리에 대해서 익히
게 될 것이다. 선형 시간(linear time)에 풀이하기가 불가능하게 보였던 많은
문제들이 Suffix 트리에 의해서 정복되었다. Suffix 트리는 아무리 강조해도
지나치지 않다. Suffix트리에 사용된 많은 기술(technique)들은 독자들이 풀어
야 할 문제를 풀이하는 기본적인 아이디어(idea)로서 빈번하게 사용될 수도
있을 것이다.

  Suffix 트리는 스트링의 내부 구조를 더욱 잘 드러내 주는 자료 구조이다.
Suffix 트리가 정확한 매칭 문제(exact matching problem)에 사용될 수도
있지만, Suffix 트리의 진정한 사용은 정확한 매칭 문제보다 훨씬 어려운, 많
은 스트링 문제를 선형 시간(linear time)에 풀이 할 수 있는데서 비롯한다.
게다가 Suffix 트리는 정확한 매칭 문제와 비정확한 매칭(inexact
matching)사이에 다리(bridge) 역할을 한다.

  Suffix 트리의 고전적인 응용은 부스트링(substring) 문제이다. 길이가 m
인 텍스트 T가 주어진다. O(m)의 전처리(preprocessing) 후에, 독자들은 길
이가 n인 모르는 스트링 S를 입력으로 받아 O(n)만에 T에서 S를 찾아내야
한다. 텍스트의 길이 m에 상관없이 - 전처리만 되어 있다면 - 입력 스트링 S
의 길이에 비례하는 시간을 소비해서 답을 구한다는 것은 얼마나 놀라운 일인
가?

  KMP(Knuth-Morris-Pratt)나 BM(Boyer-Moore) 알고리즘을 사용하여 매칭
을 시도한다면, O(m)의 시간이 걸릴 것이다. n에 비해 m이 일반적으로 훨씬
크므로 기존의 알고리즘이 실용적인 해결책이 될 수는 없다.

  알고리즘을 제시한 후, 몇몇 응용들을 살펴 볼 것이다. 마지막에 살펴 볼 응
용은 '상수 시간 최소 공통 조상 방법(the constant-time least common
ancestor method)'인데 이 결과는 깜짝 놀랄만하다.

역사

  Suffix 트리를 만드는 첫 번째 알고리즘은 1973년 Weiner - 그는 논문에서
Suffix 트리란 말 대신에 위치 트리(position tree)란 말을 사용했다 - 에 의
해 제시되었다. 몇 년 후에 공간을 좀 더 효율적으로 사용하는 알고리즘이
McCreight에 의해 제시되었다. 최근에, Ukkonen은 McCreight 알고리즘의 이
점을 모두 수용하면서, 더욱 더 간단히 설명할 수 있는, 개념적으로는 다른 -
Suffix 트리를 만드는 방법이 - 선형 시간 알고리즘을 개발하였다.

  Weiner의 결과 이후 20년 이상이 흐른 것은 사실이지만, Suffix 트리가
그 놀라운 결과에도 불구하고, 컴퓨터 과학의 주 흐름에 포함되지 못하고, 덜
주목받아 온 것은 사실이다. 그것은 1970년대의 두 논문이 이해하기에 너무
난해했기 때문이었다. 하지만 한번 잘 구현되고 나면, 많은 스트링 문제에 효
과적인 해를 제공한다. Suffix 트리 이외에는, 하나의 자료 구조가 그렇게도
많은 복잡한 스트링 문제의 효과적인 해(solution)가 될 수 있는 것이 없다.

  이 책에서는 Ukkonen의 알고리즘 만을 제시할 것이다. 설명을 위한 접근
방법은, 효과적이지 않은 알고리즘을 먼저 제시하여 설명하면서, 선형 시간으
로 점점 개선하여 나갈 것이다.

기본 정의

  임의의 스트링에서 suffix 트리를 어떻게 만드는지 살펴보기 위해서, 우리는
길이가 m인 일반 스트링 S를 고려할 것이다. 일반적으로 스트링을 구성하는
알파벳(alphabet)은 유한하며 알려져 있다고 가정한다. 한 개의 스트링 S에
대해 suffix 트리를 만드는 것을 살펴 본 다음, 스트링의 집합(set)을 다룰 수
있는 suffix 트리로 일반화(generalize)해 갈 것이다.

정의  길이가 m인 스트링 S의 suffix 트리 T는, 정확히 1부터 m까지 라
벨(label)된 m개의 마지막 노드(leaf node)를 가지는, 루트(root)를 가지는
방향성 트리(rooted directed tree)이다. 루트가 아닌 각각의 내부 노드
(internal node)는 최소한 두 개의 자식(children)을 가지고, 각각의 에지는 S
의 공백이 아닌 서브스트링으로 라벨된다. 한 노드에서 나가는(outgoing) 어떠
한 두 개의 에지도 같은 문자로 시작하는 에지 라벨을 가질 수 없다. Suffix
트리의 중요한 특징은 어떠한 잎 노드 i에 대해서도, 루트에서 그 노드까지의
에지 라벨의 접속(concatenation)은, i에서 시작하는 S의 suffix라는 것이다.
이것은 S[i...m]이라 쓴다.

  예를 들면, 스트링 xabxac의 suffix 트리는 <그림 5.1>과 같다. 루트에서 1
번 잎 노드까지의 경로(path)는 S=xabxac이다. 5번 잎 노드까지의 경로는
suffix ac이며, S의 5번째부터 시작한다.

  위에서 정의한 대로라면, 어떠한 스트링 S에 대해서도 모든 suffix가 트리
에 나타난다는 보장이 되지 않는다. S의 한 suffix가 다른 suffix의 prefix와
일치한다면, 처음 suffix는 잎 노드를 가지지 않을 것이다. 예를 들면, xabxa
에서 suffix xa는 suffix xabxa의 prefix이므로 잎 노드에서 끝나지 않는다.

  이 문제를 피하기 위해 S의 마지막 문자는, S의 다른 어디에도 나타나지
않는다고 가정한다. 이 책에서는 '$'를 마지막 문자(termination characte
r)로 사용할 것이다. 특별히 마지막에 $가 붙었다는 것을 강조하기 위해서
S$처럼 사용하기도 할 것이다.

<그림 5.1> xabxac의 suffix 트리. u와 w로 라벨된 두 개의 내부 노드는
후에 사용될 것이다.

정의  루트에서 시작하여 특정한 노드에서 끝나는, 경로의 라벨(label of a
path)은 경로상의 에지의 라벨의 순차적인 접속이다. 노드의 경로-라벨
(path-label of a node)은 루트에서 그 노드까지의 라벨이다.

정의  Suffix 트리의 임의의 노드 v에 대해서, v의 스트링 깊이
(string-depth)는 v의 라벨의 문자의 개수이다.

정의  에지 (u,v)의 중간에서 끝나는 경로는 지정된 점에서 라벨을 분할한다.
그러한 경로의 라벨은 u의 라벨에 그 분할 지점까지의 문자의 접속으로 정의
한다.

  예를 들면, <그림 5.1>에서 내부 노드 w의 라벨은 xa이다. xabx는 에지
(w,1)의 중간에서 끝나는 경로의 라벨임을 나타낸다.

동기 부여

  Suffix 트리를 만드는 방법을 시작하기 전에, 정확한 매칭 문제를 풀기 위
해 suffix 트리가 어떻게 이용되는가를 살펴보자. 즉, 길이가 m인 텍스트 T와,
길이가 n인 패턴 P가 주어졌을 때, T에서 모든 P를 O(n+m)만에 찾는다. 독
자들은 나름대로의 해를 가지고 있을 지도 모른다. Suffix 트리는 다른 접
근법을 제시한다.

  텍스트 T의 suffix 트리 T를 O(m)만에 만든다. 그 다음에 T의 유일한 경
로를 따라 P의 문자를 매칭한다. P의 문자가 다 소비(consume) 되거나, 더 이
상의 매치(match)가 발견되지 않을 때까지 수행한다. 후자의 경우 P는 T의
어느 곳에도 나타나지 않는다. 전자의 경우 마지막 매치 된 점 이후의 서브트
리의 모든 잎 노드의 라벨이, T에서 P의 시작 위치이다.

  전자의 경우 - T의 경로 상에 P의 모든 문자가 매치된 경우 - 를 이해하
는 핵심은, P가 T[j..m]의 prefix일 경우에만 T의 j에서 P가 시작한다는 것이
다. 이것은 루트에서 잎 노드 j까지의 경로의 앞부분이 P로 라벨될 때만 가능
하다.

  한 개의 공통 노드에서 나가는 모든 에지는 같은 문자로 시작하는 라벨을
가질 수 없기 때문에 그러한 매칭 경로는 유일하다. 그리고 우리는 유한개의
알파벳 문자를 가정했기 때문에 각각의 노드에서 행하는 작업은 상수 시간
(constant time)이다. 그러므로 P를 매칭하는 작업은 P의 길이에 비례한다.

  예를 들면, <그림 5.2>는 스트링 T=awyawxawxz의 suffix 트리의 일부를
보여준다. 패턴 P=aw는 T의 1,4와 7에서 시작하면서 3번 나타난다. 패턴 P는
그림의 화살표가 가리키는 곳까지 매치되며, 그 노드 이하의 모든 잎 노드의
라벨은 1,4와 7이다.

<그림 5.2> aw는 awyawxawxz에 3번 나타난다. 그것의 시작 위치는
경로-라벨이 aw인 노드의 서브트리의 잎에 나타난다.

  만약 P가 트리의 어떤 경로와 전부 매칭된다면, 매칭된 경로의 끝 아래에
있는 서브트리를 방문(traverse)함으로써 T에서의 P의 시작위치를 모두 찾을
수 있다. 그러므로 T에서 모든 P를 찾는 문제는 O(n+m)이다. 이것은 일반적
인 스트링 매칭 알고리즘과 같은 시간이 걸렸지만, 일(work)의 분포는 다르
다. 일반적인 매칭 알고리즘은 P를 전처리하기 위해 O(n)의 시간을 썼고, 탐
색을 위해 O(m)의 시간을 썼다. 반대로 suffix 트리는 O(m)의 전처리 시간에,
탐색을 위해 O(n+k)의 시간을 사용한다. 여기서 k는 T에서 P가 나타나는
횟수이다.

  k개의 시작 위치를 모으기 위해서는, 매치된 경로의 끝에서 시작하는 서브
트리에 대해, DFS 혹은 BFS같은 선형시간 트리 방문 알고리즘을 사용하
면 된다. 모든 내부 노드가 최소한 2개의 자식을 가지고 있기 때문에, 방문해
서 만난 잎 노드의 수는 방문한 에지의 수에 비례한다. 그러므로 방문에 필요
한 시간 복잡도(time complexity)는 - 비록 O(k)개의 에지의 전체적인 스트링
깊이가 k보다 많이 크더라도 - O(k)이다.

  만약 P의 위치가 1곳만 필요하다면, 그리고 전처리를 약간만 확장한다면,
탐색 시간은 O(n+k)에서 O(n)으로 줄일 수 있다. 핵심은 트리의 각 노드에
서브 트리의 잎 노드들의 가장 작은 수 1개만을 써 놓는 것이다. 이것은 T에
대한 DFS로 구현할 수 있으므로 전처리 시간 O(m)을 넘지 않는다. 트리를
패턴과 매칭할 때, 매칭이 끝난 노드나, 혹은 에지에서 끝났다면 그 에지의 아
래에 있는 노드의 번호를 출력하면 된다.

Suffix 트리를 만드는 순진한(naive) 알고리즘

  독자들의 직감(intuition)을 개발하기 전에, 우리는 스트링 S의 suffix 트리
를 만드는 직관적인 알고리즘을 제시할 것이다. 이 순진한 알고리즘은 처음에
suffix S[1..m]$의 1개의 에지를, 트리에 넣음으로써 시작한다. 그리고 연속적
으로 i를 2부터 m까지 증가시키면서 suffix S[i..m]$를 자라나는(growing) 트
리에 넣는다. Ni를 1에서 i까지의 모든 suffix를 담고 있는 중간 트리로 나타
내기로 하자.

  N1은 루트와 1로 라벨된 잎 노드사이의, 1개의 에지로 이루어져 있다. 에지
는 S$로 라벨되어 있다. 트리 Ni+1은 Ni로부터 만들 수 있다. Ni의 루트에서
시작하여 S[i+1..m]$의 prefix와 매치되는 가장 긴 경로의 라벨을 찾는다. 이
경로는 suffix S[i+1..m]$의 문자들을 루트에서 유일한 경로를 따라 문자들과
차례대로 비교해 가면서 더 이상의 매치가 일어나지 않을 때까지 수행하면 찾
을 수 있다. 노드에서 나가는 에지들은 같은 문자의 라벨로 시작할 수 없기
때문에 이 매치 경로는 유일(unique)하다. 어떠한 지점에 이르러, S$의 어떠
한 suffix도 S$의 다른 suffix의 prefix가 아니기 때문에 매칭은 중단한다. 그
지점에 이르렀을 때, 알고리즘은 어떤 노드 w에 있거나, 에지의 중간에 있을
것이다. 만약 에지 (u,v)의 중간에 있다면, 새로운 노드 w를 삽입하여 에지
(u,v)를 2개의 에지로 분리(break)한다. 새로운 노드 w는 S[i+1..m]에 있는 마
지막 매치된 문자 다음, 즉 처음으로 매치되지 않은 문자 앞에 삽입한다. 새로
운 에지 (u,w)는 (u,v)의 라벨의 일부로서 라벨된다. 그리고 또 하나의 새로운
에지 (w,v)는 (u,v) 라벨의 나머지 부분으로 라벨된다. 그 다음에 - 새로운 노
드가 만들어졌거나 이미 존재하는 노드 w를 가리키고 있거나 간에 - 알고리
즘은 새로운 에지 (w,i+1)을 w에서 시작하여 i+1로 라벨된 잎 노드로 가도록
생성한다. 그리고 새로운 에지의 라벨을 S[i+1..m]$의 매치되지 않은 부분으로
정한다.

  트리는 이제 루트에서 i+1 잎 노드까지의 유일한 경로를 지니게 되었다. 그
리고 이 경로는 S[i+1..m]$를 라벨로 가진다. 새로운 노드 w로부터 나가는 모
든 에지의 라벨은 서로 다른 첫 번째 문자로 시작함을 주목하라. 귀납적으로
한 개의 노드에서 나가는, 어떠한 2개의 에지도 같은 첫 번째 문자를 가지지
못한다는 것을 보일 수 있다.

  일반적으로 제한된 크기의 알파벳을 가정하자. 위에서 설명한 순진한 방법
은 길이가 m인 S의 suffix 트리를 만드는데 O(m2)의 시간이 걸린다.

Suffix 트리를 선형 시간에 만드는 법

  Weiner의 알고리즘이 처음 발표되기는 했지만, 공간 - 메모리 - 면에서 훨
씬 효율적이면서 속도는 동일한 Ukkonen의 알고리즘만을 살펴 볼 것이다. 또
한 Ukkonen의 알고리즘이 훨씬 이해하기 쉽기 때문이기도 하다.

Ukkonen의 선형 시간 suffix 트리 알고리즘

  Ukkonen의 알고리즘은 만드는 과정 자체도 중요하다. 이러한 on-line의 특
성은 뒤에 몇 가지 문제를 풀이하는데 사용될 것이다.

함축적인(implicit) suffix 트리

  Ukkonen의 알고리즘은 함축적 suffix 트리를 순서대로 만들어 간다. 마지막
단계에서 스트링 S의 진짜(true) suffix 트리로 변환될 것이다.

정의  S의 함축적(implicit) suffix 트리는 S$의 suffix 트리에서, 트리의 에
지 라벨에서 종료 심벌 $를 제거함으로써 얻어진다. 그리고 라벨이 없는 에지
는 제거한다. 그리고 최소한 2개의 자식을 가지지 않는 노드를 제거한다.

  S의 prefix S[1..i]의 함축적 suffix 트리는, S[1..i]$의 suffix 트리에서 위의
과정처럼 $를 제거하고, 에지와 노드를 제거함으로써 정의할 수 있다.

정의  스트링 S[1..i]의 함축적 suffix 트리를 Ti로 나타낸다. 여기서 i는 1에서
m까지이다.

  어떤 스트링 S의 함축적 suffix 트리는 원래의 S$의 suffix 트리보다 적은
잎 노드를 가질 것이다. 이 경우는 S의 suffix중 적어도 하나가, 다른 suffix의
prefix인 경우에만 그렇다. 종료 심벌 $를 이러한 상황을 방지하기 위해 S의
마지막에 추가하였다.

<그림 6.1> xabxa$의 suffix 트리

<그림 6.2> xabxa의 함축적 suffix 트리

  예를 들면, <그림 6.1>에 나타낸 xabxa$의 suffix 트리를 고려해 보자.
suffix xa는 suffix xabxa의 prefix이다. 비슷하게 a는 abxa의 prefix이다. 그
러므로 잎 노드 4와 5로 가는 에지는 오직 $로 라벨되어 있다. 이러한 에지를
제거함으로써 오로지 1개의 자식을 가지는 2개의 노드가 생긴다. 이 노드 역
시 제거한다. 결과로 만들어진 함축적 suffix 트리는 <그림 6.2>와 같다.

  비록 함축적 suffix 트리가 모든 suffix의 잎 노드를 가지고 있지 않더라도,
S의 모든 suffix를 부호화(encode)한다. 즉 각각의 suffix는 루트에서 어떤 경
로를 따라가면 반드시 존재한다. 그러나 경로가 잎에서 끝나지 않으면, 경로의
끝임을 나타내는 마크(mark)가 없다. 그러므로 함축적 suffix 트리는 원래의
suffix 트리보다 적은 정보를 담고 있다. 우리는 단지 Ukkonen의 알고리즘으
로 마지막의 진짜 suffix 트리를 얻어내는 도구(tool)로 이것을 이용할 것이다.

고수준(high level)의 Ukkonen 알고리즘

  Ukkonen의 알고리즘은 S의 모든 prefix S[1..i]에 대해 함축적 suffix 트리
Ti를 만든다. T1에서 시작하여 i를 계속 증가시켜 나감으로써 최종적으로 Tm
을 만든다. 스트링 S의 진짜 suffix 트리는 Tm으로부터 만들 수 있다. 그리고
전체 알고리즘의 시간 복잡도는 O(m)이다. 우리는 먼저 Ukkonen의 알고리즘
이 O(m3)인 방법을 먼저 제시할 것이다. 그리고 구현을 점점 최적화해 나감으
로써 O(m)의 시간을 얻을 것이다.

  Ukkonen의 알고리즘은 m단계(m phase)로 나누어진다. i+1 단계에서, Ti로
부터 Ti+1을 만든다. i+1의 각 단계는 또한 i+1번의 확장(extension)으로 나
뉘어진다. 각각의 확장은 S[1..i+1]의 i+1개의 suffix와 관련되어 진다. i+1 단계
의 j번째 확장에서, 알고리즘은 루트에서 시작하여 S[j..i]를 라벨로 가지는 경
로의 끝을 찾는다. 그 다음에 S[i+1]을 끝에 추가함으로써 서브스트링을 확장
한다. 그렇지 않으면 S[i+1]은 이미 존재하는 것이다. 그러므로 i+1단계에서
스트링 S[1..i+1]이 처음으로 트리에 붙여진다. 그 다음에 S[2..i+1], S[3..i+1],
... 등으로 계속된다. i+1 단계에서 i+1번째 확장은 S[1..i]의 빈 suffix를 확장한
다. 즉, 단지 S[i+1]을 트리에 추가 - 이미 없으면 - 한다. T1은 단지 1개의
에지를 가지며 라벨은 S[1]이다. 알고리즘은 다음과 같다.

High-level Ukkonen algorithm

Construct tree T1.

for (i=1; i<=m-1; ++i) {//phase i+1

    for (j=1; j<=i+1; ++j) {//extension j

        Find the end of the path from the root labeled S[j..i] in the

        current tree. If needed, extend that path by adding character
S[i+1],

        thus assuring that string S[j..i+1] is in the tree.

    }//for

}//for

Suffix 확장 규칙(extension rule)

  위에서 임시 코드로 기술한 것을 알고리즘으로 변환하기 위해서는, suffix
확장을 어떻게 하는지 정확히 명시해야 한다. S[j..i]=Ղ를 S[1..i]의 suffix라 하
자. j번째 확장에서, 알고리즘이 현재 트리(current tree)에서 Ղ의 끝을 찾았
을 때, suffix ՂS[i+1]이 트리에 추가되도록 Ղ를 확장한다. 이것은 아래 3가지
규칙(rule) 중의 하나를 수행한다.

규칙 1(rule 1)  현재의 트리에서, 경로 Ղ가 잎 노드에서 끝이 난다. 즉, 루트
에서 Ղ로 라벨된 경로의 끝이 잎이다. 이 트리를 수정하기 위해서, S[i+1]이
그 잎 에지의 라벨에 추가된다. node가 추가되는 것이 아니라, 단지 라벨이
변경됨에 주의하라.

<그림 33.?> 규칙 1

규칙 2(rule 2)  Ղ로 끝이 났을 때 이후의 경로가, 어떠한 문자도 S[i+1]로 시
작하지 않는다. 최소한 1개의, 라벨이 붙은 경로가 Ղ의 끝에서 계속된다.

  이 경우는 Ղ의 끝에서 시작하는 새로운 잎 에지가 만들어져야 한다. 라벨은
S[i+1]이 된다. 만약 Ղ가 에지의 중간에서 끝이 난다면, 새로운 노드 역시 만
들어진다. 새로 만들어진 잎 에지의 끝에 있는 잎 노드는 j로 라벨된다.

<그림 33.?> 규칙 2

규칙 3(rule 3)  Ղ의 끝에서 시작하는 경로가 S[i+1]를 포함한다. 이 경우 Ղ
S[i+1]은 이미 현재 트리에 존재한다. 그래서 - 함축적 suffix 트리이므로 -
아무것도 하지 않는다.

<그림 6.3> 6번째 문자 b가 추가되기 전, axabx의 함축적 suffix 트리

<그림 6.4> 6번째 문자 b가 추가된 후의, 확장된 함축적 suffix 트리

  예를 들면, <그림 6.3>에 나타난 것처럼, S=axabx의 함축적 suffix 트리를
고려해 보자. 처음 4개의 suffix는 잎에서 끝이 난다. 하지만 한 개의 문자로
이루어진 suffix x는 에지의 중간에서 끝이 난다. 6번째 문자 b가 스트링에
추가될 때, 처음 4개의 suffix는 규칙 1에 의해 확장된다. 5번째 suffix는 규칙
2에 의해 확장된다. 6번째 suffix는 규칙 3이 적용된다. 결과는 <그림 6.5>와
같다.

  이것은 다음과 같은 규칙 천이 오토마타(automata)를 실행함에 주목하라.
후에 우리는 규칙 1과 규칙 3의 상태 변화를 상수 시간에 해결함으로써
Ukkonen 알고리즘을 O(m)에 구현할 것이다.

<그림 33.?> 규칙 상태 천이 오토마타

구현과 속도 개선

  위에서 제시한 suffix 확장 규칙을 사용할 때, 한번만 S[1..i]의 suffix Ղ의
끝을 현재의 트리에서 발견하고 나면, 확장 규칙을 적용하는 데는 상수 시간
(constant time)이 걸린다. Ukkonen의 알고리즘을 구현하는데 핵심은 'S[1..i]
의 i+1개의 suffix의 끝을 어떻게 찾느냐'하는 것이다.

  순진하게 현재 트리의 루트에서 방문을 시작한다면, O(|Ղ|)만에 찾을 수 있
다. 그러한 접근법에 있어서 i+1단계에서의 j번째 확장은 O(i+1-j) 시간이 걸
린다. 그러므로 Ti로부터 Ti+1을 만드는데 O(i2)의 시간이 걸릴 것이다. 그러므
로 총 시간은 O(m3)이 된다. 이것은 참으로 어리석어 보인다. 왜냐하면, 이미
우리는 앞에서 O(m2) 시간을 소요하는 알고리즘을 알고 있기 때문이다. 하지
만 O(m3)의 방법을 이용하여, O(m)의 알고리즘으로 점차 바꾸어 나갈 것이
며, 바꾸는 방법에 관한 설명은 이해하기 쉽다.

  몇 가지 관찰(observation)과 구현 기교(implementation trick)를 이용하여
위의 알고리즘을 O(m)으로 줄일 것이다. 각각의 기교는 속도 개선을 위해 의
미 있는 휴리스틱처럼 보인다. 그러나 이 기교들을 각각 적용한다면, 최악의
경우 시간 복잡도를 반드시 줄이는 것이 아니다. 함께 적용되었을 때, 최악의
경우 선형 시간을 얻을 수 있다. 속도 개선을 위한 가장 큰 기교는 suffix 링
크(link)를 이용하는 것이다.

Suffix 링크: 구현을 위한 첫 번째 속도 개선

정의  xՁ가 임의의 스트링을 나타낸다고 하자. 단 x는 하나의 문자이고, Ձ는
서브스트링 - 비어 있을 수도 있는 - 이다. 경로 라벨 xՁ를 가지는 내부 노드
v에 대해, 경로 라벨 Ձ를 가지는 다른 노드 s(v)가 있다면, v에서 s(v)를 가리
키는 포인터를 suffix 링크(link)라 한다.

<그림 33.?> suffix 링크

  우리는 v에서 s(v)로 가는 suffix 링크를 (v,s(v))로 나타낼 것이다. 예를 들
면, <그림 6.1>에서 v가 경로 라벨 xa를 가진다고 하자. s(v)는 경로 라벨 a
를 가질 것이다. 그러면, v에서 s(v)로 가는 suffix 링크가 존재한다.

  특별한 경우로, Ձ가 비어 있는(empty) 경우, 내부 노드의 suffix 링크는 루
트를 가리키게 될 것이다. 루트는 내부 노드로 간주되지 않으므로, suffix 링
크를 가지지 않는다.

  정의에서 모든 내부 노드가 suffix 링크를 가진다고 하지는 않았지만, 실제
적으로는 모든 내부 노드가 suffix 링크를 가진다. 다음의 정리와 보조 정리를
통해서 좀 더 강력한 사실을 확인해 보자.

정리 6.1.1.  어떤 단계 i+1의 j번째 확장에서 현재 트리에 경로 라벨 xՁ를 가
지는 새로운 내부 노드 v가 추가되었다면, 현재 트리에 이미 Ձ를 경로로 가
지는 내부 노드가 존재하거나, 같은 i+1 단계의 j+1번째 확장에서, 스트링 Ձ의
끝에 내부 노드가 만들어질 것이다.

<그림 33.?> case 1

<그림 33.?> case 2

증명  j번째 확장에서(i+1단계의) 규칙 2가 적용될 때에만, 새로운 내부 노드
v가 만들어진다. 이것은 j번째 확장에서 xՁ로 라벨된 경로가 S[i+1]이 아닌 어
떤 c가 연속함을 의미한다. 이처럼 j+1번째 확장에서 트리에는 Ձ로 라벨된 경
로가 존재하고, 확실히 문자 c로 계속된다. 그렇다면 고려해야 할 2가지 경우
가 있다. Ձ로 라벨된 경로 이후에 c만 있든지, 아니면, c이외에 몇 개의 문자
가 더 있을 수 있다. Case 1: Ձ가 오직 c에 의해 연속될 때, 확장 규칙 2번에
의해서 경로 Ձ의 끝에 새로운 노드 s(v)가 만들어질 것이다. Case 2: Ձ이후에
두 개 이상의 문자가 계속 될 때, s(v)는 이미 내부 노드로 존재하고 있을 것
이다. 양쪽의 경우의 정리는 증명되었다. □

보조 정리 6.1.1.  Ukkonen의 알고리즘에서, 새로 만들어진 모든 내부 노드
는, 다음 확장이 끝나기 전까지 suffix 링크를 반드시 가진다.

증명  귀납법으로 증명한다. T1인 경우 내부 노드가 없으므로 참이다. 단계 i
의 마지막까지 주장이 참이었다고 가정하고 그 다음 한 단계 i+1을 고려하자.
위의 정리에 의해서 j번째 확장에서 새로운 노드 v가 만들어졌을 때, v의
suffix 링크가 가리키는 s(v)가 j+1번째 확장에서 발견되거나 만들어질 것이
다. 각 단계의 마지막 확장에서는 어떠한 내부 노드도 만들어지지 않는다. 그
러므로 i+1 단계에서 만들어진 내부 노드의 모든 suffix 링크는 그 단계가 끝
나기 전에 알 수 있다. 그러므로 Ti+1의 내부 노드는 모두 suffix 링크를 가진
다. □

  위의 보조 정리는 매우 중요하다. 이것을 다르게 표현하면 다음과 같다.

보조 정리 6.1.2.  어떤 함축적 suffix 트리 Ti에 대해서도, 내부 노드 v가 경
로 라벨 xՁ를 가진다면, Ti에는 경로 라벨 Ձ를 가지는 노드 s(v)가 존재한
다.

  위의 정리는, 가장 최근에 추가된 내부 노드를 제외하고, 모든 내부 노드가
suffix 링크를 이미 가진다는 것이다. 가장 최근에 추가된 노드는 다음 확장에
서 suffix 링크를 가지게 된다. Suffix 링크가 어떻게 속도 개선을 위해 사용
되는지 살펴보자.

Ti+1을 만들기 위해 suffix 링크를 따라 간다

  i+1 단계에서 알고리즘은 S[1..i]의 suffix S[j..i]를 j번째 확장에서 위치시킴
을 상기하자. 여기서 j는 1에서 i+1까지 증가시킨다. 순진하게, 이것은 스트링
S[j..i]를 현재 트리의 루트에서 비교하면 된다. Suffix 링크는 이것을 획기적으
로 줄일 수 있는 지름길이다. i+1단계에서 처음 2번의 확장은 설명하기 쉽다.

  스트링 S[1..i]의 끝은 반드시 Ti의 잎에서 끝나야 한다. 왜냐하면 S[1..i]가
그 트리가 표현하는 가장 긴 스트링이기 때문이다. 그것은 그 suffix의 끝을
찾는 것을 쉽게 만든다. 트리가 만들어져 감에 따라, 현재의 가장 긴 스트링
S[1..i]에 해당하는 잎을 가리키는 포인터를 유지해 나간다. 그리고 그것의
suffix 확장은 규칙 1에 의해 조정한다. 그러므로 어떠한 단계의 처음 확장은
특별하다. 알고리즘이 현재의 가장 긴 스트링(current full string)의 끝을 가리
키는 포인터를 유지하고 있으므로, 상수 시간이 걸린다.

  스트링 S[1..i]가 xα라 하자. 여기서 x는 하나의 문자이고, α는 서브스트링
- 비어 있을 수도 있는 - 이다. 그리고 (v,1)을 1번 잎 노드로 들어가는 트리
-에지라 하자. 즉 노드 v는 1번 잎 노드로 가는 경로의 마지막에 위치하는 내
부 노드이다. 다음에 알고리즘은 Ti에서 유도된 현재 트리에서 S[2..i]=α의 끝
을 찾아야 한다. 핵심은 노드 v가 루트이거나, Ti의 내부 노드라는 것이다. 만
약 루트이면, α의 끝을 찾는 것은 순진한 알고리즘처럼 α로 라벨 된 경로를
계속 따라가면 된다. 그러나 v가 내부 노드라면, 위의 보조 정리에 의해서 v
는 s(v)를 가리키는 suffix 링크를 가지게 된다. 더군다나 s(v)가 스트링 α의
prefix인 경로-라벨을 가지므로, 스트링 α의 끝은 반드시 s(v)의 서브 트리에
서 끝이 나야 한다. 결론적으로 현재 트리에서 α의 끝을 찾는 것은, 루트에서
시작하여 전체 경로를 뒤질 필요 없이 s(v)에서 시작하여 탐색을 시작하면 된
다. 이것이 알고리즘에서 suffix 링크를 포함하는 중요한 이유이다.

<그림 33.?> βγ=α, β는 α의 prefix이다

  두 번째 확장을 자세히 설명해보자. γ를 에지 (v,1)의 에지-라벨이라 하자.
α의 끝을 찾기 위해서는, 잎 노드 1에서 위로 v를 찾아간다. 다음에 v의
suffix 링크를 따라 s(v)를 찾아간다. 다음에 s(v)에서 아래로 γ로 라벨된 경
로를 따라 간다. 그 경로의 끝이 바로 α의 끝이다. 경로 α의 끝에서 트리는
suffix 확장 규칙에 의해서 수정된다. 이로써 j=1과 j=2인 경우의 처음 두 확
장을 완성하였다.

  j>2일 때, S[j..i]에서 S[j..i+1]의 임의의 스트링에 대해서도 확장하기 위해서
는, 위의 아이디어를 반복하면 된다. 현재 트리의 스트링 S[j-1..i]의 끝에서 시
작한다. 기껏해야 1개의 노드를 - 그 노드는 루트이거나 suffix 링크를 가지는
노드 v이다 - 거슬러 올라(walk up)간다. γ를 그 에지의 라벨이라 하자. v가
루트가 아니라고 가정하자. Suffix 링크를 따라 v에서 s(v)를 방문한다. 그리
고 s(v)에서 아래로 내려가기 시작한다. S[j..i]의 끝까지 γ로 라벨된 경로를
따라 간다. 최종적으로, 확장 규칙에 따라 S[j..i+1]을 확장한다.

  처음의 두 확장과 j>2일 때의 확장 사이에는 약간의 차이가 있다. 일반적으
로, S[j-1..i]의 끝은 그 자체가 suffix 링크를 가지는 노드에서 끝날 수 있다.
그 경우, 알고리즘은 그 suffix 링크를 따라 간다. j-1번째 확장에서 규칙 2가
적용되었을 때 조차라도 - 그래서 S[j-1..i]의 끝이 새로 만들어진 내부 노드
w에 있을 경우 - w의 부모가 루트가 아니라면, w의 부모 노드는 이미(위의
정리 6.1.1에서 증명한 것처럼) suffix 링크를 가지고 있다. 이처럼 j번째 확장
에서 알고리즘은 한 개 이상의 에지를 거슬러 올라가지 않는다.

<그림 6.5>  i+1단계의 j>1번째 확장. S[j-1..i]로 라벨된 경로의 끝에서 노드
v로, 기껏해야 한 개의 에지를 거슬러 올라간다. 다음에 suffix 링크 s(v)를
따라 간다. 다음에 γ를 따라 내려간다. 그 다음에 suffix S[j..i+1]을 적당한
확장 규칙에 따라 확장한다.

단일 확장 알고리즘(single extension algorithm: SEA)

  위의 설명들을 합쳐, suffix 링크를 구현할 때, i+1단계의 j≥2번째 확장은
다음과 같다.

단일 확장 알고리즘(single extension algorithm: SEA)

Begin

    1. S[j-1..i]의 끝 혹은 바로 위의 첫 번째 노드 v - 물론 이 노드는 루트
이거

      나 suffix 링크를 가져야 한다 - 를 찾는다. 이것은 현재 트리의
S[j-1..i]

      의 끝에서 기껏해야 한 개의 에지를 거슬러 올라가면 된다. γ를 - 비
어

      있을 수도 있는 - v와 S[j-1..i]의 끝 사이의 스트링을 나타낸다고 하자.

    2. 만약 v가 루트가 아니라면, v에서 s(v)로의 suffix 링크를 따라 간다.
그리

      고 s(v)에서 스트링 γ를 따라 내려간다. 만약 v가 루트라면, 루트에서

      S[j..i]의 경로를 따라 - 순진한 알고리즘처럼 - 간다.

    3. 확장 규칙을 사용하여, S[j..i]S[i+1]이 확실히 트리에 추가되도록 한다.

    4. j-1번째 확장에서 새로운 내부 노드 w가 만들어 - 확장 규칙 2에 의
해 -

      졌다면, 스트링 α는 반드시 노드 s(w) - w에서의 suffix 링크의 끝 노
드

      - 에서 끝이 나야 한다. w에서 s(w)로 가는 suffix 링크 (w, s(w))를
추가

      한다.

End.

  알고리즘이 현재의 가장 긴 스트링 S[1..i]의 포인터를 유지한다고 하자. i+1
단계에서의 첫 번째 확장은 아래나 위로 따라가는 것이 필요하지 않다. 더구
나, i+1단계에서의 첫 번째 확장은 항상 규칙 1이 적용된다.

지금까지 한 것이 무엇인가?

  Suffix 링크의 사용은 각각의 확장 때마다 루트에서 따라가는 방법에 비해
서는 확실히 실용적인 개선이 있었다. 하지만 그것의 사용이 최악의 경우 수
행 시간을 개선하였는가?

  대답은 앞에서 기술한대로 No이다. Suffix 링크의 사용이 아직은 시간 복잡
도를 개선시키지 못하였다. 그러나 여기서 우리는 최악의 경우 알고리즘 수행
시간을 O(m2)으로 만드는 기교(trick)를 도입할 것이다. 이 기교는 다른 suffix
알고리즘에서도 트리를 만들고 유지하는 핵심적인 부분이다.

첫 번째 기교: 건너뜀/헤아림 기교(skip/count trick)

  j+1번째 확장의 단계 2에서 알고리즘은 노드 s(v)에서 γ로 라벨된 경로를
따라 아래로 내려간다. s(v)에서 시작하는 경로에는 반드시 γ가 있다는 것을
상기하자. 그대로(directly) 구현하였을 때는, γ를 따라 가는 것은 |γ| - 그
경로에 있는 문자의 수 - 에 비례하는 시간이 걸린다. 그러나 skip/count 라
는 간단한 기교를 이용하면, 그 경로에 있는 노드의 수에 비례하는 방문 시간
으로 줄일 수 있다. 그렇게 되면, 임의의 단계에서 아래로 내려가는 것의 시간
복잡도는 기껏해야 O(m)인 것을 보일 수 있다.

기교 1(trick 1)  g가 γ의 길이를 나타낸다고 하자. 그리고 s(v)에서 나가는
어떠한 두개의 에지 라벨도 같은 문자로 시작할 수 없다는 것을 상기하자. 그
래서 γ의 첫 번째 문자는 s(v)에서 나가는 1개의(exactly one) 에지 상에 첫
번째 문자로 반드시 나타나야 한다. g'이 그 에지에 있는 문자의 수를 나타낸
다고 하자. 만약 g'이 g보다 작다면, 알고리즘은 그 에지에 있는 다른 문자는
더 이상 볼 필요가 없다. 단지 그 에지의 마지막 노드까지 건너뛴다(skip). 거
기서 g=g-g'이 된다. 그리고 변수 h=g'+1로 설정한다. 그리고 매치 되는 다음
에지를 찾기 위해 나가는(outgoing) 에지 - 첫 번째 문자가 h인 - 를 살핀다.
일반적으로, 알고리즘이 경로상의 다음 에지를 발견했을 때, 현재의 g값을 그
에지의 문자의 수 g'과 비교한다. 만약 g가 최소한 g'보다 크다면, 알고리즘
은 에지의 끝까지 노드를 건너뛴다. g=g-g', h=h+g'이 된다. 그리고 첫번재
문자가 h인 에지를 찾는다. 이것을 반복한다. g가 g'보다 작거나 같은 에지에
도착했을 때, 알고리즘은 에지의 문자 g개를 건너뛰고, s(v)로부터의 경로 γ
가, 그 라벨의 정확히 g 문자를 따라갔다는 것을 보장한 채 알고리즘은 종료
한다. <그림 6.6>을 보라.

<그림 6.6> skip/count 기교. i+1단계에서 서브스트링 γ는 길이가 10이다.
s(v)에서 나가는 경로에 γ의 복사가 있다. 4개의 노드를 건너뛴 다음, 마지막
에지에서 3문자 다음까지가 γ이다.

  간단하고 명확한 구현 명세 - 각각의 에지에 있는 문자의 수를 알고, 상수
시간에 어떠한 위치의 문자를 S에서 뽑아 낼 수 있는 것 - 를 가정하면서,
skip/count 기교를 사용한 효과는 γ 경로에서, 한 노드에서 다음 노드로 움직
이는 비용은 상수 시간 - 물론 알파벳의 수가 상수 개라 가정 - 이다. 그러므
로, 경로를 방문하기 위한 전체 시간은 노드 수에 비례하고, 문자의 수에 비례
하지는 않는다.

  이것은 유용한 휴리스틱(heuristic)이다. 그러나, 이것이 최악의 경우 시간
제약에 이점이 있는가? 다음 정리는 즉각적인 대답을 유도한다.

정의  노드 u의 노드-깊이(node-depth)는 루트에서 u까지의 경로의 노드
수이다.

정리 6.1.2.  (v,s(v))를 Ukkonen의 알고리즘 동안 방문되는 임의의 suffix 링
크라 하자. 그렇다면, v의 노드-깊이는 기껏해야 s(v)의 노드-깊이보다 1크다.

증명  에지 (v,s(v))를 방문했을 때, v의 어떠한 내부 조상 - 경로-라벨
(path-label)이 xβ라 하자 - 도, 경로-라벨 β를 가지는 노드로 suffix 링크를
가지고 있다. xβ는 v까지의 경로의 prefix이다. 그래서 β는 s(v)까지의 경로
의 prefix이다. 그리고 이것은 v의 어떠한 내부 조상의 suffix 링크도 s(v)의
조상으로 향함을 나타낸다. 더구나, 만약 β가 비어 있지 않다면, β로 라벨된
노드는 내부 노드이다. v의 어떠한 2개의 내부 조상의 노드-깊이는 달라야 하
므로, v의 각각의 조상은 s(v)의 서로 다른 조상으로 향하는 suffix 링크를 가
진다. 이것은 s(v)의 노드-깊이가 최소한 1 - 루트 때문에 - 더하기 v의 내부
조상 - 경로 라벨이 1문자 이상인 - 의 수이다. v가 가질 수 있는 유일한 여
분의(extra) 조상은 경로-라벨(x로 나타내기로 하자)의 길이가 1인 내부 조상
이다. 그러므로, v는 s(v)보다 기껏해야 1이 큰, 노드-깊이를 가진다. <그림
6.7>를 보라. □

<그림 6.7> 경로 xα상의 모든 노드 v에 대해, 대응하는 노드 s(v)는 경로
α상에 있다. 그러나, s(v)의 노드-깊이는 v의 노드-깊이보다 1작거나,
같거나, 클 수도 있다. 예를 들면, xab로 라벨된 노드는 깊이가 2이다. 반면에
ab의 노드-깊이는 1이다. xabcdefg로 라벨된 노드의 노드-깊이는 5이다.
반면에 abcdefg의 노드 깊이는 6이다.

정의  알고리즘이 진행될수록, 알고리즘의 현재의 노드-깊이(current
node-depth)는 알고리즘에 의해서 가장 최근에 방문된 노드의 노드-깊이이
다.

정리 6.1.1.  skip/count 트릭을 사용하면, Ukkonen 알고리즘의 어떠한 단계
도 O(m)의 시간이 걸린다.

<그림 33.?> skip/count 트릭의 O(m) 한계(bound). 결과는 m을 넘을 수
없다. 감소는 2m번 했다. 그렇다면 증가는 기껏해야 3m번이 최고다.

증명  단계 i에서 i+1≤ m의 확장이 있다. 한번의 확장에서 알고리즘은 suffix
링크를 가지는 노드를 찾기 위해, 위로 1개의 에지만을 따라간다. suffix 링크
를 따라 간 다음, 몇 개의 노드를 아래로 따라 간다. suffix 확장 규칙을 적용
한 다음, 마지막에 suffix 링크를 추가할 수도 있다. 우리는 이미 확장 때마다,
아래로 따라가는 것을 제외한 모든 연산이 상수 시간이 걸린다는 것을 알고
있다. 그래서 아래로 따라 내려가는 것에 대한 시간 분석만이 필요하다. 우리
는 이것을 현재의 노드 깊이(current node depth)가 단계에 따라 어떻게 변하
는지를 살펴봄으로써 완성할 것이다.

  위로 따라 가는 것은 어떤 단계의 확장에서도 현재의 노드 깊이를 기껏해야
1 감소시킨다(왜냐하면 기껏해야 1개의 노드를 따라가기 때문에). 또한 suffix
링크를 따라가는 것 또한 노드-깊이를 기껏해야 1 감소시킨다(정리 6.1.2에 의
해서). 그리고 각각의 아래로 따라가는 에지 방문은 더 깊은 노드-깊이를 가
지는 노드쪽으로 움직인다. 이처럼 모든 단계에 걸쳐 현재의 노드-깊이는 기
껏해야 2m번만큼만 줄어든다. 그리고 어떠한 노드도 m보다 큰 깊이를 가질
수 없기 때문에, 현재 노드-깊이의 가능한 전체의 증가는 전 단계에 걸쳐 3m
으로 한정된다(m=3m-2m). 이것은 전 단계에 걸쳐 아래로 내려가는 에지 방
문의 수가 3m으로 한정됨을 의미한다. skip/count 기교를 사용하면, 아래로의
에지 방문 시간은 상수이다. 그래서 한 단계에서 아래로 따라 가는 것의 전체
시간은 O(m)이다. 정리는 증명되었다. □

  알고리즘에는 m단계가 있으므로, 아래의 결과를 즉시 유도할 수 있다.

보조 정리 6.1.3  Ukkonen의 알고리즘은 suffix 링크를 사용하여, O(m2)에
구현할 수 있다.

  알고리즘의 O(m2)은 한 단계의 O(m)에 m 단계를 곱하여 얻었다. 위에서
분석한 시간 복잡도는 한 단계에 대한 것이므로, 이러한 곱셈이 필요하다. 그
러한 시간 복잡도로 구현하기 위해서 바꾸어야 하는 부분은, 단계의 경계 부
분의 고려이다. 그것은 쉽게 구현할 수 있다.

  여기서 독자들은 우리가 처음에 순진하게 구현했던 O(m2) 알고리즘보다 나
아지지 않은 결과에 의아해 할지도 모른다. 그렇다. 우리가 시간 복잡도 상으
로는 얻은 성과가 없지만, 굉장한 개념적인 성과(conceptual progress)가 있었
기 때문에, 약간의 상세한 부분만 추가하면 O(m)의 시간 복잡도를 얻을 수
있다. 이것을 위해 상세한 구현을 위한 약간의 조언과, 작은 기교 2개가 더 필
요하다.

상세한 구현을 위한 약간의 조언

  우리는 곧 suffix 트리를 만드는데 O(m)의 시간이 드는 알고리즘을 완성할
것이다. 그러나, 그 목표에 하나의 장벽이 있다: suffix 트리는 Ԩ(m2)의 공간
이 필요하다. 지금까지 기술한대로, suffix 트리의 에지-라벨은 전체적으로 Ԩ
(m)보다 많은 문자를 포함할 수 있다. 알고리즘의 시간은 최소한 출력 크기만
큼은 되기 때문에, 그러한 많은 문자는 O(m)의 한계를 불가능하게 만든다. 스
트링 S=abcdefghijklmnopqrstuvwxyz를 고려해 보자. 각각의 suffix 링크는 서
로 다른 문자로 시작한다. 그러므로 루트에서 시작하는 에지는 26개이다. 이것
은 각각이 완전한 suffix를 가지므로, 모두 26×(26+1)/2의 문자를 필요로
한다. 알파벳 크기보다 긴 스트링은 몇 문자는 반복될 것이다. 그러나, 임의의
길이 m인 스트링을 구성하므로, 여전히 전체적인 에지-라벨의 길이는 Ԩ(m)개
의 문자보다 클 수 있다. 그러므로 O(m)의 시간 복잡도를 얻기 위해서는 에
지-라벨을 나타내는 다른 전략이 필요하다.

에지-라벨 압축(edge-label compression)

  에지 라벨을 붙이는 간단한 다른 방법이 존재한다. 트리의 에지에 서브스트
링 전부를 쓰는 대신에, 에지에 인덱스의 쌍(pair of indices)을 쓴다. 각각의
인덱스는 S의 서브스트링의 시작과 끝을 나타낸다. <그림 6.8>을 보라. 알고
리즘은 스트링 S의 복사본을 유지하고 있기 때문에, S의 임의의 특정한 문자
를 참조하는 것은 상수 시간에 가능하다. 그러므로, 에지의 라벨을 표시하는데
상수개의 심벌을 위한 공간만이 필요하다.

<그림 6.8> 왼쪽 트리는 스트링 S=abcdefabcuvw의 suffix 트리의 일부이다.
에지 라벨이 명시적으로 쓰여져 있다. 오른쪽 트리는 에지-라벨이 압축된
형태로 나타나 있다. 2,3으로 라벨된 에지가 8,9로도 라벨될 수 있다는 것에
주목하라.

  예를 들면, Ukkonen의 알고리즘에서 에지를 따라 매칭할 때, 알고리즘은 에
지 라벨의 인덱스 쌍을 이용하여 S로부터 원하는 문자를 뽑아낸다. 그리고 그
문자에 대해 비교를 수행한다. 이 확장 규칙은 또한 이러한 라벨링(labeling)
기교를 이용하여 쉽게 구현 가능하다. i+1 단계에서 확장 규칙 2가 적용되었
을 때, 새로 만들어진 에지에 라벨을 (i+1,i+1)로 붙인다. 그리고 잎 노드에서
규칙 1이 적용되었을 때, 잎 에지의 라벨을 (p,q)에서 (p,q+1)로 고친다. q가 i
에서 i+1로 바뀌므로 라벨이 (p,i+1)로 된다는 것을 귀납적으로 쉽게 증명할
수 있다.

  에지-라벨을 나타내는데, 인덱스의 쌍(pair)을 사용함으로써, 어떠한 에지에
도 두 개의 숫자만 기록하면 된다. 그리고 에지의 수는 기껏해야 2m-1이기
때문에, suffix 트리는 O(m)개의 심벌만을 사용하고 그러므로 O(m)의 공간만
을 필요로 한다. 이것은 트리가 O(m)만에 만들어 질 수 있다는 것을 더욱 그
럴듯하게 만든다. 완전하게 구현한 알고리즘이 에지의 서브스트링을 명시적으
로 쓰는 것은 아니지만, 우리는 여전히 에지나 경로상의 라벨을 통하여 쉽게
그 서브스트링을 접근할 수 있다.

추가된 2개의 기교, 이것으로 끝

  연속적인 단계와 확장에서 상호 작용하는, 확장 규칙에 대한 두 가지 관찰
로부터 2가지의 구현상의 기교를 제시할 것이다. 이러한 기교는 원하는 선형
시간 알고리즘을 마침내 제시하게 될 것이다.

관찰 1(observation 1): 규칙 3은 쇼(show)를 멈춘다(Rule 3 is a show
stopper)  어떠한 단계에서도, j번째 확장에서 규칙 3이 적용되면, 그 단계가
끝날 때까지 이어지는 확장(j+1에서 i+1까지)은 항상 규칙 3이 적용된다. 이유
는 다음과 같다. 규칙 3이 적용되었을 때, 현재 트리에서 S[j..i]로 라벨된 경로
는 반드시 S[i+1]로 계속해야 한다. 그리고 S[j+1..i]로 라벨된 경로도 마찬가지
이다. 그러므로 규칙 3이 j+1,j+2,...,i+1번째 확장까지 계속 적용된다. 아래의
오토마타를 다시 한번 상기하자.

<그림 33.?> 규칙 적용의 상태 천이 오토마타

  확장 규칙 3이 적용될 때, 흥미 있는 suffix는 이미 트리에 존재하므로 해야
할 것은 아무 것도 없다. 게다가, suffix 링크는 규칙 2가 적용된 후에만 트리
에 추가 될 필요가 있다. 이러한 사실과 관찰 1은 다음과 같은 구현상의 기교
를 얻는다.

기교 2(trick 2)  어떠한 i+1단계에 대해서도 규칙 3이 처음 적용될 때 끝낸
다. 만약 이것이 j번째 확장에서 일어나면, S[k..i], 단 k>j, 의 어떠한 스트링
에 대해서도 명시적으로 스트링의 끝을 찾을 필요는 없다.

  i+1단계에서의 처음 규칙 3이 적용된 이후의 확장은 묵시적으로
(implicitly) 수행된다. 이것은 S[j..i]의 끝을 명시적(explicitly)으로 발견하는 j
번째 확장과 대조적이다. 그러한 종류의 확장은 명시적 확장(explicit
extension)이다.

  기교 2는 수행해야 할 일을 줄이는데 좋은 휴리스틱이다. 그러나 그것이 최
악의 경우 수행 시간을 단축시키는지 확실하지 않다. 그러기 위해서는, 마지막
1개의 관찰과 기교가 더 필요하다.

관찰 2: 한 번 잎은 영원한 잎이다(Once a leaf, always a leaf)  Ukkonen
알고리즘의 임의의 지점에서 잎 노드가 만들어지고 j로(S의 j에서 시작하는
suffix에 대한) 라벨 되었다면, 그 노드는 알고리즘이 진행되는 동안 연속하여
만들어지는 트리에서 항상 잎 노드로 남는다. 알고리즘이 현재의 잎 노드 이
후에 잎 에지를 확장시키는 방법이 없기 때문에 이것은 사실이다. 더 상세히
설명하면, j로 라벨된 잎이 나타나기만 하면, 어떠한 연속하는 단계에 대해서
도 j번째 확장까지는 규칙 1이 적용된다. 그래서 한 번 잎은 계속해서 잎이
다.

  1단계에서 1번 잎이 만들어 졌다. 그러므로, 어떠한 i단계에 대해서도, 1번째
확장에서 시작한 후에 계속된 확장에, 규칙 1 혹은 규칙 2가 적용되는, 초기
순서(initial sequence)가 있다. ji가 이 순서의 마지막 확장을 나타낸다고 하자.
규칙 2가 적용되면 항상 새로운 잎 노드가 만들어지기 때문에, 관찰 2로부터
ji ≤ ji+1임을 알 수 있다. 즉 규칙 1 혹은 규칙 2가 적용되는 확장의 초기 순
서가 연속된 단계에서 움추리지(shrink) 않는다. 이것은 i+1 단계에서 1에서 ji
까지의 모든 명시적인 확장을 하지 않아도 된다는 것을 의미한다. 대신에, 그
러한 확장을 묵시적으로 하기 위해서는 상수 시간이 필요하다.

  기교를 설명하기 위해서, 함축적 suffix 트리의 에지상의 라벨은 S[p..q]를
의미하는 두 개의 인덱스 p,q로 나타나있다는 것을 상기하자. 또한 Ti의 어떠
한 잎 노드에 대해서도, 인덱스 q가 i이며, i+1단계에서 q가 증가하여 i+1이
됨을 상기하자. 이것은 각각의 suffix에 S[i+1]이 추가된 트리를 나타낸다.

기교 3  i+1 단계에서, 잎 노드가 처음으로 만들어지고, 서브스트링 S[p..i+1]
로 라벨된다면, 에지의 라벨에 인덱스 (p,i+1)을 쓰는 대신에, (p,e)를 쓴다. e
는 '현재의 끝'을 나타내는 심벌이다. 심벌 e는 각 단계의 처음에 i+1로 설정
되는 전역 인덱스(global index)이다. i+1 단계에서, 알고리즘은 최소한 1에서
ji까지는 규칙 1이 적용될 것을 알고 있기 때문에, 그러한 ji개의 명시적인 확
장을 할 필요가 없다. 대신에, 변수 e를 증가시키는 상수 시간의 일이 필요하
다. 그리고, ji+1번째 확장에서 시작하는 몇 확장을 위해 명시적인 확장을 한
다.

  정리하면, 다음과 같다. 규칙 1, 규칙 2 그리고 규칙 3의 오토마타에서 기교
3은 규칙 1의 적용을 없앤다. 기교 2는 규칙 3의 적용을 없앤다. 기교 1은 규
칙 2를 적용할 때, 상수 시간에 한다는 것을 보장한다.

마지막 문구(punch line)

  기교 2와 기교 3 덕분에 i+1 단계에서의 SEA 알고리즘을 사용한 명시적인
확장은 ji+1번째 확장에서 첫 번째로 규칙 3이 적용되는 확장까지 뿐이다(혹은
i+1번째 확장이 끝날 때까지). 그러한 명시적인 확장 이전과 이후의 모든 확장
은 묵시적으로 행해진다. 이것을 요약하면, i+1단계는 다음과 같이 구현된다.

단일 단계 알고리즘(single phase algorithm): SPA

Begin

    1. 인덱스 e를 i+1로 증가시킨다.(기교 3에 의해 이것은 1에서 ji까지의 모

       든 묵시적인 확장을 바르게 수행한다.)

    2. SEA를 사용하여, ji+1에서 시작하는 계속된 확장을 명시적으로 수행한
다.

       처음으로 규칙 3이 적용되는 j*번째 확장을 만나거나, 이 단계에서의
모

       든 확장이 끝날 때가지 수행한다.(기교 2에 의해 이것은 j*+1에서 i+1
까

       지의 모든 추가적인 묵시적 확장을 바르게 수행한다.)

    3. 다음 단계를 준비하기 위해, ji+1을 j*-1로 설정한다.

End.

  단계 3은 ji+1을 정확하게 설정한다. 왜냐하면 확장 규칙 1 혹은 규칙 2가 적
용되는 확장의 초기 순서는 규칙 3이 처음 적용되는 곳에서 반드시 끝나야 하
기 때문이다.

  SPA 알고리즘의 핵심은 i+2 단계가 j*번째 확장에서 명시적인 확장을 시작
한다는 것이다. 여기서 j*는 i+1 단계에서 마지막으로 수행된 명시적인 확장이
다. 그러므로, 두 개의 연속한 단계는 기껏해야 한 개의 인덱스 - 명시적인
확장이 수행된 - j*를 공유한다. <그림 6.9>를 보라.

                 1 2 3 4 5 6 7 8

                               8 9 10 11

                                      11 12 13 14 15 16

                                    16 17

<그림 6.9> Ukkonen 알고리즘의 가능한 상황. 각 라인은 알고리즘의 한
단계를 나타낸다. 각 숫자는 각 단계에서의 명시적인 확장을 나타낸다. 이
그림에서는 4단계와 17개의 명시적인 확장이 있다. 어떠한 두 개의 연속적인
단계도 양쪽 단계에서 수행되는 같은 명시적인 확장은 1개의 인덱스 뿐이다.

  게다가, i+1 단계는 스트링 S[j*..i+1]이 어디서 끝나는지를 알면서 중단한다.
그러므로 i+2 단계에서의 j*의 반복적인 확장은 위로 따라가는 것이나, suffix
링크 따라가는 것이나, 노드를 건너뛰는 것 없이 j*를 위한 suffix 확장 규칙
을 수행할 수 있다. 이것은 어떠한 단계에서의 첫 번째 명시적인 확장도 상수
시간이 걸린다는 것을 의미한다. 처음에 주장했던 O(m)의 결과를 증명하는
것은 이제 쉬워졌다.

정리 6.1.2  Suffix 링크와 구현상의 기교 1,2와 3을 사용하면, Ukkonen 알고
리즘은 T1에서 Tm까지의 함축적 Suffix 트리를 전체 시간 O(m)에 만든다.

증명  어떠한 단계에서 모든 묵시적 확장에 드는 비용은 상수이다. 그러므로
전체 알고리즘에서 O(m)이다.

  알고리즘이 명시적 확장을 수행해 감에 따라, 알고리즘이 현재 수행하고 있
는 명시적 확장에 해당하는 인덱스

ba
r
j

를 고려해 보자. 알고리즘의 전체 수행에
걸쳐서,

ba
r
j

는 줄어들지 않는다. 그러나, 두 개의 연속한 단계에 걸쳐 같은 채로
남아 있다. 알고리즘은 전부 m단계이기 때문에, 그리고

ba
r
j

가 m에 한정(bound)
되기 때문에, 알고리즘은 2m개의 명시적인 확장만을 수행할 뿐이다. 앞서 언
급했듯이, 명시적 확장을 위한 시간은, '상수 시간' + '확장을 위해 아래로 내
려가는데 건너뛰는 노드의 수에 비례하는 시간' 이다.

  알고리즘의 전체에 걸쳐, 아래로 내려가는 모든 동작에 의해 건너뛰는 노드
의 총 수를 한정짓기 위해, 정리 6.1.1에서 사용한 것과 비슷한 방법을 사용한
다. 연속된 확장 - 비록 다른 단계의 확장일지라도 - 에서 현재의 노드-깊이
가 어떻게 변하는지 살펴 볼 것이다. 핵심은 1 단계 이후의 어떤 명시적인 첫
번째 확장도 j*부터 시작한다는 것이다. j*는 그 전 단계의 마지막 명시적인
확장이다. 그러므로, 확장과 확장사이에서 현재의 노드-깊이는 변하지 않는다.
그러나 - 정리 33.1.1의 증명에서 말한 것 같이 - 각각의 명시적인 확장에서
현재의 노드-깊이는 처음으로 기껏해야 2(에지를 위로 따라 가는 것과 suffix
링크를 따라 가는 것)줄어 든다. 그리고 그 확장에서 아래로 따라 가는 것은
각각의 노드를 건너뛸 때마다 현재의 노드-깊이를 1 증가시킨다. 노드-깊이의
최고 값은 m이므로, 그리고 오직 2m개의 명시적인 확장이 있으므로, 알고리
즘 전체에서 아래로 내려 가는 동안의 건너뛰는 노드의 최고 횟수는 O(m)으
로 한정된다. 모든 것이 끝났으며, 정리는 증명되었다. □

진짜 suffix 트리 만들기

  마지막의 함축적 suffix 트리 Tm은 진짜 suffix 트리로 O(m)에 변환할 수
있다. 먼저, S의 끝에 종료 심벌 $를 추가한다. 그리고 Ukkonen 알고리즘이
이 문자로 계속 수행되도록 한다. 효과는 어떠한 suffix도 어떤 다른 suffix의
prefix가 아니라는 것이다. 그래서 Ukkonen 알고리즘의 수행의 결과는 각각의
suffix가 잎에서 끝나는 함축적 suffix 트리, 즉 명시적 suffix 트리가 된 것이
다. 추가적으로 변경해야 할 사항은 모든 잎 에지에 있는 인덱스 e를 m으로
바꾸는 것이다. 이것 역시 표준 트리 방문 기법을 사용하여 O(m)에 가능하다.
이러한 변경이 가해졌을 때, 최종적인 트리는 진짜 suffix 트리이다.

  요약하면,

정리 6.1.3  Ukkonen 알고리즘은 스트링 S의 모든 suffix 링크를 따라가며,
O(m) 시간에, 진짜 suffix 트리를 만든다.

cgccgc$를 만드는 과정의 예

  cgccgc$를 만드는 과정을 차례대로 살펴보자. 이 원본 그림은 Visio를 사
용하여 그린 것인데, 설명은 영문을 사용하였다. 위에서 사용한 용어를 사용하
므로, 읽는데 큰 어려움이 없을 것이라 믿는다. 최종적으로 만들어진 suffix
트리의 모양은 <그림 ?>와 같다.

<그림 ?> cgccgc$의 suffix 트리

스트링 집합에 대한 일반화된 suffix 트리

  우리는 지금까지, {p.116, 1998.4.16, continued...}

실제의 구현

  아래에 suffix 트리를 구현한 Java 코드와 C++코드를 같이 리스트한다.
C++코드는 비록 Visual C++의 MFC 라이브러리로 작성된 것이기는 하지
만, 여러분이 사용하는 컴파일러에 맞도록 수정이 가능할 것이다. 후배 중
Java 매니아가 있어 Java의 소스도 같이 리스트한다.

시간 복잡도(time complexity)의 의미

연습문제

1.


34. 몬테 카를로

34. 몬테 카를로(Monte
Carlo)

  몬테 카를로(Monte Carlo)는 모나코(Monaco) 공국(duchy)의 도시로 관영
(official managing) 도박장(gambling house)으로 유명하다. 도박장의 도박 기
계들을 컴퓨터로 모의 실험(simulation)하기 위해서 어떤 방법을 사용해야
할까? 바로 확률(probability)을 사용하는 것이다. 그러므로, - 누가 처음으로
이 용어를 사용한지는 모르지만 - 컴퓨터에서 확률을 사용하여 시뮬레이션을
하는 것을 몬테 카를로 시뮬레이션(Monte Carlo simulation)이라 한다.

  일반적으로 몬테 카를로를 설명하는 책에서는 은행을 찾아오는 손님과 나가
는 손님을 시뮬레이션하기 위해서 대기 큐(wait queue)를 사용하는 것으로 시
작하지만, 이 장에서는 대기 큐를 설명하지는 않을 것이다.

  필자는 컴퓨터공학을 전공하기는 했지만, 개인적으로 수학과, 통계학과와 물
리학과 사람들에게 존경심을 갖고 있다. 그들은 일반적으로 매우 기민하며, 똑
똑하다. 또한 그들은 '확률'이라는 거대한 거인에 대해서 너무나도 자세히 알
고 있으며, 컴퓨터공학도(computer engineer)들이 막히는 부분 - 다 그런것은
아니지만, 컴퓨터공학도들은 일반적으로 확률 개념이 들어간 곳은 블랙 박스
(black box)로 간주한다 - 에 대해서 전혀 막힘이 없다.

  하지만, 독자들이 지레 겁을 먹을 필요는 없다. 이 장에서는 확률이라는 거
대한 빙산의 일각(minute)만을 다룰 - 대부분의 응용에서 일각만으로도 충분
하다 - 것이므로, 하루에 소화하기에 충분한 내용이다.

  확률을 시뮬레이션하기 위해서 반드시 필요한 함수들이 난수(random
number) 발생 함수이다.

  난수 관련 함수

  필자는 처음에 - 대학교 3학년까지도 - 우리가 중학교 수학책에서 보았던,
난수표(random number table)를 C 라이브러리가 모두 가지고 있는 줄 알고
있었다. 그래서 random()이라는 함수는 그 난수표에서 임의의 한 항목에서 시
작하는 일련의 난수들을 꺼내 오는 줄 알았다. 그 당시 나의 의문은

    ■ 시작하는 난수는 어떻게 지정하는가?(처음의 random()은 매번 같

    은 값을 리턴했다)

    ■ 그 큰 난수표를 정말로 모두 갖고 있는 것일까?

였다. 첫 번째 의문은 randomize()로 간단히 해결되었다. randomize()는 PC의
타이머(timer)를 이용하여 시작 난수를 초기화한다. 두 번째 의문은 꽤 오래
지속되었지만, 그러려니 하고 생각을 했다■.

             필자는 실제로 학원에서 C를 강의하면서 C라이브러리가 난수표를 가지고
          있다고 설명을 한 적이 있다. 95년 대구의 시사컴퓨터학원에서 강의를 들
          었던 학생들에게 미안함을 표시한다. 그 당시 난 Pascal만을 사용했기 때
          문에, C의 문법에조차 문외한이었다. 블락(block) 개념을 바로 이해하지 못
          했던 필자는 Pascal의 begin ... end; 를 생각하여, C의 블락이 { ... }; 라고
          설명한 적도 있다. 하지만, 이 문장은 에러가 아니었는데, ; 이 어셈블러
          (assembler) NOP(no operation)로 번역되기 때문인 것도 후에야 알았다.

  후에 난수를 발생시키는 복잡하지만 짧은 알고리즘을 보고 매우 놀랐다. 이
러한 알고리즘은 일련의 이진 비트열(binary bit sequence)을 이동(shift)하거
나, 접거나(fold)하여 가짜 난수(pseudo random number)를 발생한다.

  처음 이진 비트열을 설정하는 것이 srand()이다. 또한 타이머를 이용하여
srand()를 설정하는 것이 randomize()이다. 난수 관련 표준 함수는 아래와 같
다.

srand()		rand()가 난수 발생을 시작할 첫 번째 수를 설정한다. 첫 번

        째 수가 같으면, 일련의 rand()호출은 연속된 같은 난수를

        발생한다. 이것을 씨를 뿌린다(seed)고 하는데, 함수의 첫 번

        째 s는 Seed의 약자이다.

rand()		srand()에서 설정된 첫 번째 수를 이용하여, 난수를 발생한

        다. 일반적으로 난수의 범위는 0~RAND_MAX까지이다.

        RAND_MAX는 라이브러리의 헤더 파일에 정의(#define)되어

        있다.

randomize();	타이머를 이용하여, Seed를 설정한다.

random();	사용자가 파라미터에 지정한 범위의 난수를 발생한다.

  0에서 99사이의 난수를 발생시키기 위해서는

random(100);

처럼 사용한다. 하지만 위 문장은 매번 같은 일련의 난수를 발생한다. 즉, 프
로그램을 시작해서 10,97,34,... 순으로 난수가 발생했다면, 다음에 실행해도
10,97,34,... 순으로 난수가 발생한다. 이것은 처음에 사용하는 Seed가 일정하기
때문인데, 타이머를 이용하여 Seed를 초기화 해 주어야 한다.

    randomize();

    random(100);

randomize()는 한 번만 호출해도 된다. random(100)은 rand()%100과 같다.

  5,6,7,8중 한 개의 난수를 발생시키기 위해서는 어떻게 할까? 이것은 간단하
다.

5+random(4)

  그렇다면, 연속적이지 않은 12,34,101,3000중 한 개의 난수는 어떻게 발생할
까? 이것은 참조표(look-up table)을 이용하면 간단하다.

    int table[]={12,34,101,3000};

    int r;

    r=table[ random(4) ];

  0에서 1사이의 실수(real number)를 발생시키기 위해서는 어떻게 해야하는
가? 아래의 문장이 적절한 해결책이다.

(double)rand()/(double)RAND_MAX■

             캐스팅(casting)을 해주지 않으면, 0 혹은 1만이 될 것이다.

  실제로 위의 문장은 0과 1사이의 균일 분포(uniform distribution)를 시뮬
레이션 한다. 이것은 0과 1사이의 어떤 수가 나올 확률도 동일함을 의미한다
(0.1이 나올 확률과 0.9가 나올 확률은 같다).

  균일 분포가 아닌 경우를 살펴보기 전에, 먼저, srand()의 유용한 사용 예를
살펴보자.

srand()의 사용이 반드시 필요한 경우

  rand()를 사용하는 프로그램을 디버깅(debugging)하는 경우, 매번 다르게
발생하는 난수가 문제가 된다면, srand()를 사용한다. 예를 들어 srand(100)은
프로그램이 시작 될 때마다 일련의 같은 난수를 발생할 것이므로, 다음 수 예
측이 가능하다.

  0에서 640사이의 임의의 난수 1,000개를 기억할 필요가 있다고 하자. 이것을
기억하기 위해 배열을 사용할 것인가? 하지만, 더 좋은 해결책이 있다. 즉
srand()의 처음 수를 설정하여, 연속된 1000개의 rand()의 리턴 값을 이용하는
것이다.

  아래의 예제는 밤 하늘의 별을 시뮬레이션 한 것이다. 이것은 srand()의 기
능을 이용하지 않고는 어려운 작업이다.

#include <stdio.h>

#include <stdlib.h>

#include <conio.h>

#include <dos.h>

#include <graphics.h>

void main() {

    int gd=DETECT,gm;

    initgraph(&gd,&gm,"e:\\borlandc\\bgi");

    int i,j,x,y;

    while (!kbhit()) {

        for (j=0;j<2;++j) {

            srand(100);//같은 seed를 설정하여 같은 난수를 발생할 수 있다

            for (i=0;i<1000;++i) {

                x=rand()%640;

                y=rand()%480;

                putpixel(x,y,j*15);

            }

            delay(1000);

        }

    }

    closegraph();

}

             srand()를 이용하여 별을 시뮬레이션 한다.

  룰렛 휠(roulette wheel)

  아래 그림과 같은 룰렛 휠을 생각해 보자.

             룰렛 휠의 각 부분(section)은 서로 다른 확률을 가진다.

  위의 룰렛 휠을 시뮬레이션하기 위한 직관적인 방법은 간단하다. 각 부분
(part)의 합이 100%이므로, 1~100사이의 난수를 발생시켜 이의 범위가 각 부
분에 들어가는지 검사하면 된다. 난수를 발생시키기 위해

random(100)+1

을 사용할 수 있다.

  난수가 1~37 사이이면, A가 선택된 것이다. 38~68 사이이면 B가 선택된 것
이다. 68~83, 84~93과 94~100은 각각 C, D와 E가 선택된 것이다.

  하지만, 구현 시에는 B가 선택되었는지를 확인하기 위해, 38과 68에 대해 if
문을 사용할 필요는 없다. 이것은 참조표(look-up table)을 이용하면, 1번의 if
문으로 구현할 수 있다. 참조표를 다음과 같은 일차원 배열(one dimensional
array)로 구현한다.

    int roulette[]={37,68,83,93,100};

    char output[]={'A','B','C','D','E'};

  그리고 roulette[]을 인덱스 0부터 스캔하여, 난수가 처음으로 roulette[]보다
작아지는 곳의 인덱스(index)를 i라고 하면, output[i]를 출력하면 된다.

  아래의 예제를 참고하라. 아래의 예에서 roulette[]에는 처음에 각 부분의 확
률이 들어 있다. 이 값을 적절히 변환하는 방법은 기초적인 기교(technique)이
다.

#include <stdio.h>

#include <stdlib.h>

#include <iostream.h>

#include <conio.h>

#define SIZE        (sizeof roulette/sizeof roulette[0])

#define KEY_ESC     27

#define KEY_ENTER   13

int roulette[]={37,30,15,9,6};

char output[]={'A','B','C','D','E'};

void main() {

    int i,sum=0;

    char ch=0;

    for (i=0;i<SIZE;++i) {

        sum+=roulette[i];

        roulette[i]=sum;

    }//for

    int fortune;

    randomize();

    while (ch!=KEY_ESC) {

        fortune=random(100)+1;

        for (i=0;i<SIZE;++i)

            if (fortune<roulette[i]) {

                cout << output[i];

                break;

            }//for

        ch=getch();

    }//while

}//main

  위의 프로그램은 각각의 확률로 A~E를 출력한다. 횟수가 많아질수록 각각
의 문자들은 정의된 확률에 가까워 질 것이다.

  위 프로그램에서는 룰렛 휠의 각 부분에 속하는지 검사하기 위하여 for문을
사용하고 있다. 부분의 수가 n이라면 최악의 경우 for문 내부의 if문은 n번 실
행될 것이다. 여기서 독자에게 한가지 숙제를 제시한다.

   for문의 사용 없이 부분을 바로 구할 수는 없는가? 그렇다면, 어떤
전처리(preprocessing) 과정이 필요한가?

  π 구하기

  이제 π■를 확률을 이용하여 구해보자. 우리는 원의 면적이 πr2이라는 것

             π는 원의 반지름을 1이라고 했을 때, 원호의 1/2이 차지하는 비율이다.
          이것은 원의 반지름 r에 상관없이 일정한데, 약 3.1415정도이지만, 순환하
          지 않는 무한소수가 된다. 이 값을 π라고 한다. 이러한 기하학에 관한 내
          용은, 그리스 시대에 거의 완성되었으므로, 대부분 그리스 문자를 사용하
          여 나타낸다.

을 알고있다. 만약 원의 반지름 r=1이라면, 원의 면적은 π가 될 것이다.

  아래와 같이 반지름이 1이고 중심이 (0,0)인 원을 고려해 보자.

             반지름이 1인 원의 면적은 π이다. 원의 1/4의 면적은 π/4이다.

  위 그림의 원의 방정식은 x2+y2=1이다.

  원 상에 있는 임의의 좌표 (x,y)는 원의 방정식을 만족해야 한다. 그러므로,
-1~1사이의 난수 x와 y를 발생했을 때, 이 점이 원의 내부에 있기 위해서는

x2+y2≤1

식을 만족할 것이다.

  1사분면만을 고려해보자. 0~1사이의 난수 좌표 (x,y)를 발생시킬 때, 횟수가
많아질수록, 위 부등식(inequality)을 만족하는 (x,y)의 비율은 π/4에 가까워
질 것이다. 그러므로, 구해진 값에 4를 곱해주면, π 근사값을 구할 수 있다.

  아래에 프로그램을 리스트하였다.

#include <iostream.h>

#include <stdlib.h>

//#include <conio.h>

#define MAX_N       100000L

void main() {

    long sum=0,i;

    double x,y,f,pie;

    randomize();

    for (i=0;i<MAX_N;++i) {

        x=(double)rand()/(double)RAND_MAX;

        y=(double)rand()/(double)RAND_MAX;

        f=x*x+y*y;

        if (f<=1.0) ++sum;

    }//for

    pie=((double)sum/(double)MAX_N)*4;

    cout << pie << endl;

    //getch();

}

  프로그램의 결과는 시행 때마다 다르지만, 3.14에 근사한 값이 출력된다. 시
행 횟수가 늘어날수록 π에 더 근사한 값이 출력될 것이다.

   CDF와 PDF의 시뮬레이션

  실습문제

1.


잠자는 파일을 깨우자:

Z-압축된 파일에서의 패턴 매칭

Amihood Amir, Gary Benson, Martin Farach

1993년 10월 20일

translated by seojt@comeng.ce.kyungpook.ac.kr on 1998.4.11

요약

  저장된 정보의 폭발적인 증가로, 현재는 패턴 매칭에 있어서 새로운 모델,
즉 압축 매칭(compressed matching)을 필요로 한다. 이 모델에서 사람들은
압축된 텍스트의 크기에 비례하는 시간으로, 압축을 풀지 않고, 특정 패턴의
위치를 모두 찾아내려고 시도한다.

  가장 효과적이면서 일반적으로 사용하는 압축 알고리즘은 적응적
(adaptive)이다. 즉, 각각의 압축된 심벌에 의해서 표현되는 텍스트는 데이터
에 의해 동적으로 결정된다. 결과로써 서브스트링(substring)의 부호화
(encoding)는 위치에 의존적이다. 그러므로 같은 서브스트링도 압축된 텍스트
에서 나타나는 위치에 따라 다르게 보인다.

  이 논문에서 UNIX Z-압축된 파일을 풀지 않고 패턴 매칭하는 것을 고려한
다. Z-압축은 Lempel-Ziv의 적응적 압축 기법의 변형이다. 압축된 텍스트의
길이를 n이라 하고, 패턴의 길이를 m이라하면, 우리가 제시하는 알고리즘은
첫 번째 패턴을 O(n+m2) 혹은 O(nlogm+m)만에 찾는다.

  우리는 또한 압축 매칭 알고리즘을 평가하는 새로운 기준, 즉 여분의 공간
(extra space)를 도입한다. 우리는 사용된 여분의 공간과 알고리즘의 시간 복
잡도의 상호 교환(trade-off)을 위해 어떻게 우리의 알고리즘을 변형하는지 보
여준다.

1. 도입

  최근 몇 년간 데이터 저장을 위한 공간(storage)과 데이터 양(gathering)의
폭발적인 증가가 있었다. 지금 몇몇 기관들은 처리하기에 너무 방대한 데이터
덩어리(archive)를 가지고 있다. 놀랄만한 양의 데이터 때문에, 가상적으로
모든 데이터들은 압축된 형태로 보존되고 있다.

  이러한 저장된 정보의 만연(proliferation)은 데이터 압축 기술의 새로운 요
구를 필요로 한다. 압축된 데이터의 압축율이 높고, 압축 알고리즘의 속도가
빠르다는 것으로 이제 충분하지 않다. 우리는 이제 압축된 크기에 비례하는
시간과 공간을 갖는, 즉 압축을 풀지 않고 패턴 매칭을 하는 알고리즘을
필요로 한다.

  압축 매칭 문제(compressed matching problem)는 Amir와 Benson에 의
해 다음과 같이 형식적으로(formally) 정의된다: Փ=s1...su가 알파벳 Գ={a1,...,aq}
상에서 길이 u인 텍스트 스트링이라 하자. Փ.c=t1...tn은 길이가 n≤u인 Փ의 압
축이다.

입력: 압축된 텍스트 Փ.c=t1...tn과 패턴 P=p1...pm

출력: 패턴인 si에서 시작하는 첫 번째 위치 i, 즉 si+j-1=pj, j=1,...,m

  Amir와 Benson은 또한 압축 매칭이 o(u)라면 효과적(efficient),
O(nlogm+m)이라면 거의 효과적(almost efficient), O(n+m)이라면 최적
(optimal)이라고 정의했다.

  첫 번째 압축 매칭 알고리즘은 Eilam-Tsoreff와 Vishkin 그리고
Amir,Landau와 Vishkim의 논문의 부산물(side effect)이다. Eilam-Tsoreff와
Vishkin의 기교(technique)는 실행-길이 압축(run-length compression)된 데이
터의 압축 스트링 매칭을 위한, 간단한(trivial) 최적의 알고리즘을 제시한다.
Amir,Landau와 Vishkin은 2차원 압축 매칭(two dimensional compressed
matching)을 위한 효과적인 알고리즘을 제시하였다. 그 알고리즘의 실행 시간
은 o(u)지만 Թ(

u
over
m

)이다. 2차원 실행 길이 압축의 크기는 n=

ro
ot
u

이면 작아지므
로, 그 알고리즘은 분명히 최적(optimal)과는 거리가 멀다. Amir와 Benson은
2차원 실행 길이 압축을 위한 거의 최적인(almost optimal) O(nlogm) 알고리
즘을 개발하였다. 그리고 Amir, Benson과 Farach는 이 문제의 최적 O(n) 알
고리즘을 고안하였다.

  후자의(latter) 알고리즘의 큰 어려움은 텍스트가 2차원이라는 사실에 기인
한다. 비교에 있어서, 압축된 스트링 매칭을 위한 알고리즘은 단순
(straight-forward)하다. 사실상 어떠한 비적응적(non-adaptive)인 일차원 압
축 - 실행-길이(run-length)나 허프만 부호화(Huffman encoding) - 도, 쉬운
압축 매칭 알고리즘을 가지고 있다. 간단히 패턴을 압축한 다음, 당신이 가장
좋아하는 패턴 매칭 알고리즘을 압축된 텍스트와 패턴에 적용하면 된다. 약
간의 부가적인 일(extra work)이 필요할 수도 있다. 예를 들면, 첫 번째와 마
지막 패턴 요소는 실행-길이 압축인 경우는 분리되어 다루어져야 한다. 그리
고 허프만 압축에서는, 부호화(encoding)된 각각의 심벌의 시작 비트가 발견
될 필요가 있다.

  Lempel-Ziv 같은 적응적 압축(adaptive compression)은 새로운 도전이
다. 적응적 압축에서는, 각각의 압축된 심벌에 의해서 나타나는 텍스트는 데이
터에 의해 동적으로 결정된다. 결과로서, 같은 서브스트링도 텍스트 상에서의
위치에 따라, 다르게 부호화된다. 그러므로 패턴을 부호화하는 것은 무익하다.
압축된 형태의 텍스트에 그러한 서브스트링은 나타나지 않는다.

  이 논문에서 LZW 압축으로 알려진, UNIX Z-압축에서의 일차원 압축 매칭
(one dimensional compressed matching)을 고려한다. 이것은 Welch에 의해
도입된 Lempel-Ziv 압축의 변형(variant)이다. 이것은 적응적 압축이지만,
Lempel-Ziv의 것보다 부호화와 복호화(decoding)가 훨씬 간단하다.

  우리 논문의 주된 공헌은 다음과 같다:

  ● 우리는 처음으로, 적응적 압축에서의 거의 최적인(almost optimal) 압축
매칭 알고리즘을 제시한다. 이 알고리즘은 LZW 압축의 성질(nature)에 대한
새로운 고찰에 기초한다. 우리의 알고리즘은 간단하고, 빠르고 코딩하기 쉽다.
알고리즘의 상수(constants)는 순진한(naive) '압축 푼 후 찾기
(decompress-then-search)'보다 작다.

  ● 우리는 압축 매칭 알고리즘을 평가하는 새로운 기준(criteria)으로, 여분
의 공간(extra space)을 도입한다. 몇몇 응용에서는 사용 가능한 여분의 공
간이 제한적이다. 그러므로, O(n) 공간을 사용하는 - 압축된 파일 자신뿐 아
니라 - 최적의 알고리즘은, 바람직(feasible)하지 않다. 우리는 o(n)의, 최적으
로는 O(m)의 여분의 공간을 - 길이 n의 압축 파일에다가 - 사용하는 알고리
즘이 필요하다.

  ● 여분의 공간과 시간의 상호 교환(tradeoffs)을 달성하기 위하여 기본적인
알고리즘을 변경하는 것을 보여준다. 결과가 <표 1>에 요약되어 있다.

Algorithm

Time

Extra Space

Currently known

Time: O(u)

Space: O(n+m)

Optimal for mㄼ√n

O(n+m2)

O(n+m2)

Almost Optimal

O(nlogm+m)

O(n+m)

Hashing implementation

c<m, √u

O(nc2+m2) w.h.p.

O(n/c+m2)

Worst case tradeoff

Ձ=min(c,m)

Ղ=min(c,√u)

O(nՁՂ+nՂlogm+m2)

O(n/c+m2)

<표 1> 결과 요약

  이 논문은 다음과 같이 구성되어 있다. 2장은 LZW 압축을 정의하고 그 압
축 알고리즘의 몇 가지 흥미 있는 성질을 증명한다. 3장에서는 우리의 기본적
인 알고리즘을 개설(outline)한다. 4장에서 거의 최적 알고리즘을 만드는 구현
에 대해서 논의한다. 5장에서 여분의 공간 기준을 도입하고, 몇 시간/공간 상
호 교환을 분석한다. 마지막으로 6장에서 많은 미해결 문제(open problem)와
함께 결론을 맺는다.

2. 준비물(preliminaries)

  정의: 알파벳 Գ={a1,...,aq}상에서 Փ=s1...su라 하자. Փ의 Z-압축 Փ.Z는 스트링
Փ.Z[1]...Փ.Z[n]이다. 단, i=1,...,n에 대해, 1 ≤ Փ.Z[i] ≤ n+q-1 이다. Z-압축은
사전 트리(dictionary trie) TՓ의 도움으로 구성된다. 사전은 노드의 개수가
(n+q+1)개이고, 노드가 Գ에 있는 심벌과 0에서 n+q까지의 숫자로 라벨(label)
된 트리(tree)이다. TՓ에서 노드 p의 스트링(string of a node p)은 루트에
서 p까지의 경로(path)의 노드 라벨의 접속(concatenation)이다. 사전과 압축된
스트링은 다음과 같이 만들어진다:

  1. TՓ는 루트가 (0,ԫ)로 라벨된 q+1개의 노드를 가지는 트리로 초기화된다.
루트는 각각이 1,...,q로 숫자가 매겨진 q개의 자식(children)을 가진다. 자식 i
는 ai로 라벨된다. 초기화된 사전과 빈 압축 스트링은 빈 스트링(null string)
ԫ를 반영(reflect)한다.

  2. 사전과 압축된 스트링이 s1...si를 반영한다고 가정하자. l을 압축 스트링
Փ.Z에서 마지막으로 정의된 요소(last defined element)라 하자. l'은 TՓ에서
마지막으로 숫자가 매겨진 노드라 하자. 스트링 si+1,...,sj를 가지는 노드 p가 T
Փ에 존재할 때, si+1...sj를 si+1...su의 가장 긴 접두어(prefix)라 하자. Փ.Z[l+1]이
값 p를 가지도록 한다. 만약 j≠u 라면, l'+1로 숫자가 매겨지고, sj+1로 라벨
된 새로운 노드를 TՓ에서 노드 p의 자식으로 추가(attach)한다. 사전과 압축
스트링은 이제 s1...sj를 반영한다.

  압축 스트링의 각각의 요소는 청크(chunk)를 표현한다. Փ.Z[i]에 의해 표
현되는 청크(chunk represented by Փ.Z[i])는(Փ.Z[i]의 청크(Փ.Z[i]'s
chunk)) Փ.Z[i]가 가리키는 노드의 스트링이다.

예: Գ={a,b,c}, Փ=aabbaabbabcccccc. Փ.Z=1,1,2,2,4,6,5,3,11,12.

TՓ=

  TՓ를 만드는 처음 몇 과정은 다음과 같다. 먼저 루트를 만들고 알파벳의 모
든 문자를 차례대로 루트의 자식으로 추가한다.

<그림 1> Σ에 있는 모든 문자가 트리에 추가된다.

  σ를 읽기 시작한다. a를 읽는다. 트리에 이미 있으므로, 다음 문자 a를 읽
는다. 트리에 없으므로 aa를 라벨로 하는 새로운 노드를 추가하고, 추가된 노
드 전까지의 라벨 a를 1로 부호화한다.

<그림 2> aa까지 읽는다. a가 1로 부호화된다.

  ab를 읽는다. b를 읽는 순간, 사전에 없으므로, 노드를 트리에 추가한다. a
를 1로 부호화한다.

<그림 3> ab를 읽는다. a가 1로 부호화된다. 그러므로 지금까지의 부호화된 스트링은 1,1이다.

  bb를 읽는다. bb를 라벨로 하는 새로운 노드 6이 트리에 추가되고, b가 2로
부호화된다.

<그림 4> bb를 읽는다. b가 2로 부호화된다.

  ba를 읽는다. ba를 라벨로 하는 새로운 노드 7이 트리에 추가된다. b는 2로
부호화된다.

<그림 5> ba를 읽는다. b가 2로 부호화된다.

  aab를 읽는다. aab를 라벨로 하는 새로운 노드 8이 트리에 추가된다. aa는
4로 부호화된다. 지금까지 부호화된 스트링은 1,1,2,2,4이다.

<그림 6> aab를 읽는다. aa가 4로 부호화된다.

  압축되지 않은 스트링 Փ는 길이가 u이지만, 압축된 Փ.Z는 길이가 n임에 주
의하라.

정리 2.1  스트링 Փ가 주어졌을 때, O(u)만에 TՓ와 Փ.Z를 만들 수 있다. 스트
링 Փ.Z가 주어졌을 때, O(u)만에 TՓ와 Փ를 만들 수 있다.

  아래의 정리와 보조정리는 우리의 알고리즘에 핵심(crucial)이다. 아래의 정
리는 사전 트리(trie)에서의 노드 번호와 압축 스트링의 청크의 위치 사이에
사상(mapping)을 표현한다.

정리 2.2  Փ.Z에서 1 ≤ l ≤ n-1인, 위치 l에 할당된 값은, 사전 트리(trie)에
서 번호가 l+q로 매겨진 노드를 정의(definition)하고 생성(creation)한다.

증명: l에 대한 귀납법(induction)으로 증명한다. l=1일 때, Փ.Z[1]=i, 여기서
s1=ai이다. 추가된 노드는 스트링 s1s2를 가진다. 이것은 트리에 없으므로, 번호
가 i인 노드의 자식으로서 새로운 노드가 만들어진다. 새로운 노드의 라벨은
(q+1,s2)이다. Փ.Z[l]이 값을 할당받았을 때, 새로운 노드 l+q가 만들어졌다고
가정하자. 또한 트리(trie)와 압축 스트링이 s1...si를 반영한다고 하자. si+1...sj가
트리의 노드 스트링으로 나타나면서, si+1...sn의 가장 긴 접미어(longest prefix)
일 때, 노드 번호를 p라 하자. 그러므로 스트링이 si+1...sj+1인 노드는 사전에 없
다. 그러므로 (l+q+1,sj+1)로 라벨된 새로운 노드가 p의 자식으로서 추가될 것
이다. ■

따름 정리 2.3  Փ.Z[i]=l이라 하자. Փ.Z[i]의 청크의 첫 번째 심벌은 Փ.Z[l-q]의
청크의 첫 번째 심벌이다. Փ.Z[i] 청크의 마지막 심벌은 Փ.Z[l-q+1] 청크의 첫
번째 심벌이다.

정의 2.4  P=p1...pm을 패턴 스트링이라 하자. 패턴의 내부 서브스트링(internal
substring) pi...pj는 i > 1 이고 j ≤ m 인 서브스트링이다. 내부 서브스트링인
청크를 내부 청크(internal chunk)라 한다. 공백이 아닌, 패턴의 접두어
(prefix)로 끝나는 청크를 접두어 청크(prefix chunk)라 한다. 접두어 청크의
표현 접두어(representing prefix)는 그것으로 끝이 나는, 가장 긴 패턴 접
두어이다. 공백이 아닌, 패턴의 접미어로 시작하는 청크를 접미어 청크
(suffix chunk)라 한다. 접미어 청크의 표현 접미어(representing suffix)는
그것으로 시작하는, 가장 긴 패턴 접미어이다.

정리 2.5  Փ.Z를 길이가 n인 압축된 스트링이라 하고, P를 길이가 m인 패턴
이라 하자. 기껏해야 m개의 청크가 P의 정확한 접미어(exact suffix)인 내부
청크(internal chunk)가 기껏해야 m2개 있다. n개의 접두어 청크(prefix
chunk)가 있을 수 있다.

증명: 청크가 루트에서 노드까지의 경로란 것을 상기하자. 기껏해야 m2개의
내부 서브스트링(internal substring)이 존재하고, 그들 모두는 루트에서 시작
해야 한다. 그렇다면, m2개의 내부 청크보다 많을 수 없으며, 이것들 중 기껏
해야 m개가 접미어(suffix)이다. 그러나 이러한 m개의 접미어의 모든 자손들
(descendents)은 우리가 정의한 접미어 청크임을 주의하라.

  모든 청크는 다음 예에서처럼 접두어 청크일 수 있다. Փ=aaa...a,여기서 a를
패턴의 첫 번째 심벌로 두자.


39. 계산 기하학(computational geometry)

39. 물체의 충돌 계산: 계
산 기하학(computational
geometry)

  게임 만들기

  이 장의 주제에 별 관심이 없는 독자들 중 많은 독자들은 게임 만들기에는
관심이 있을는지 모른다. 컴퓨터 게임 중, 아케이드(arcade) 형식의 블락 게임
(block game)은 가장 구현하기가 쉬운 게임이다. 이러한 블락 게임은 대부분

             블락 게임: 일반적으로 내부적으로 2차원 배열을 사용한다. 테트리스는 대
          표적인 블락 게임이다.

내부적으로 2차원 배열을 사용한다. 80년대 후반 소련에서 돌풍적인 인기로
시작한 테트리스(tetris)를 비롯, 퍼즐 버블(puzzle bubble), 토러스(torus)등의
대부분의 게임이 여기에 속하며, 요사이도 천리안, 하이텔 등의 공개 자료실에
개인이 만든 블락 게임들이 가끔 올라오곤(upload) 한다. 여기서 필자가 독자
들에게 권하는 사항은 다음과 같다.

"반드시 블락 게임류 - 가장 강력히 추천하는 것은 테트리스 - 를 직접
C++로 제작해 보기 바란다. 테트리스를 혼자 힘으로 제작할 수 있다면,
(과장되어 말한다면) 당신은 C++의 전문가 일뿐만 아니라, 전문
프로그래머의 능력을 갖춘 셈이다."

  바둑(Go)이나, 장기 같은 게임은 2차원 배열을 사용하기는 하지만, 블락 게
임이라고 하지 않고, 판 게임(board game)- 게임이 판 위에서 진행되므로 -
이라고 한다. 이러한 게임은 수 읽기 등의 인공 지능적인 요소를 갖추어야 하
므로, 잘 구현하기 어려운 게임류에 속한다. 바둑의 수 읽기를 어떻게 구현■
할 것인가?

            '바둑에서 수 읽기를 구현하는 법: 게임트리'를 보라.

             보드 게임: 일반적으로 내부적으로 2차원 배열을 사용하지만, 판(board)에
          서 벌어지는 게임이므로 보드 게임이라 한다. 바둑(Go)은 대표적인 판 게
          임이다. 일반적으로 보드 게임은 수 읽기 등의 인공지능적인 요소를 필요
          로 한다.

  구현하기 어려운 것은 아니지만, 기술 습득이 까다롭고, 많은 시간을 들여야
만들 수 있는 게임에는 3차원 게임이 있다. 먼저 3차원 모델링(modelling)에
필요한 개념들을 알아야하며, C++에서 이들 함수들을 자유롭게 구사할 수 있
어야 한다. 하지만, 이것은 뒤 단계에 비하면, 비교적 쉬운 편이다. 어떻게 복
잡한 3차원 물체를 보기 좋게 모델링■하는가? 프로그래머의 미적인 감각이

            2차원 대상(object)은 그린다(draw)란 용어를 사용하지만, 3차원 대상에 그
          린다란 용어는 적절하지 않다. 3차원 물체를 그리는 것을 모델링이라고 한
          다.

필요하다. 물론 많은 상업용 프로그램들은 모델링을 위한 전문 아티스트
(atrist)를 고용하여 작업하고 있다(당신이 3차원 게임을 만들려고 한다면,
Microsoft의 DirectX 라이브러리를 사용하라).

  2차원 슈팅 게임(shooting game)은 블락 게임 다음으로 구현하기 쉬운 편
이다(물론 다양한 캐릭터를 디자인하는 것은 구현과는 별개의 문제이다). 독
자들은 80년대 중반까지 꾸준한 인기를 누렸던, 겔러그, 엑스리온 등의 고전
게임■을 기억하는가?

            요사이 - 오늘은 1998년 6월 14일: 한국이 오늘 멕시코한테 1:3으로 역전
          패 한 날 - MAME 에뮬레이터로 이러한 게임을 즐기는 것이 가능하다.

             아케이드 게임(슈팅 게임) 영웅: 오락실에서 흔히 볼 수 있는 게임이다. 우
          리는 이 장에서 아케이드 게임의 스프라이트의 충돌을 검사하는 루틴을
          구현할 것이다.

  2차원 혹은 3차원 상에서 게임을 만들 때, 물체(object)■의 충돌을 검사하

            게임에서 움직이는 물체들은 객체(object)라고 하지 않고, 스프라이트
          (sprite)이라 한다. 이것은 C++의 객체와 구분되는, 게임의 움직이는 객체
          를 지칭하므로 적절하다. 하지만, 이 장에서는 게임을 만드는 것이 아니므
          로, 스프라이트라는 용어는 사용하지 않는다.

는 것은 필수적인 요소이다. 물체가 사각형인 경우는 문제가 쉽다. 하지만, 다
각형(polygon object) 물체, 원(circle), 혹은 선(line)의 충돌을 어떻게 검사하
는 것이 가장 빠른가? 이 장에서는 각도를 구하기 위하여, 삼각함수
(trigonometric function)를 사용하지 않는 빠른 기법을 제시한다.

  이러한 기법은 기하학(geometry)의 범주에 속하지만, 일반 기하학 - 확실하
지는 않지만, 기하학에서는 자(ruler), 연필, 각도기 등을 이용하여 문제를 풀
이할 것이다 - 과는 다르게, 계산 중심이므로 계산 기하학(computational
geometry)이라 한다.

  사각형(rectangle) 충돌의 탐지

  2개의 사각형의 충돌 여부를 검사하는 것은 초보 게임 프로그래머가, 2개의
스프라이트(sprite) 간의 충돌을 검사하기 위해서 흔히 사용하는 방법이다.

  아래의 그림은 필자의 초보시절 게임을 만들 때 사용했던, 지네(centipede)
스프라이트의 일부이다(게임의 이름은 '영웅(Hero)'이었다).

             게임의 지네 스프라이트: 스프라이트의 움직임대로 모든 그림을 그려야(2
          차원이라면) 한다.

  이러한 스프라이트의 충돌을 검사하기 위해서, 일반적으로 스프라이트 마다
사각형을 유지한다. 실제 스프라이트는 사각형 정보 외에, 힘(power), 무기를
발사하는 총(gun)의 좌표, 스프라이트 형태(style), 터지고 나서의 모양 등등
다양한 정보를 유지해야 한다(C++의 객체 지향 개념이 일반화된 지금, 이것은
얼마나 쉬운 작업인가?).

  스프라이트를 둘러싸는 가장 작은 사각형인 바운딩 사각형(bounding
rectangle)을 스프라이트 주위에 그려보자.

             바운딩 사각형: 스프라이트의 주위를 둘러싸는 가장 작은 사각형을 의미
          한다.

  바운딩 사각형이 정의되었다면, 스프라이트의 충돌을 검사하는 것은 사각형
의 겹침(overlap) 유무를 검사하면 된다.

             빠른 검사: (a) 사각형이 겹친다면, x3은 x2보다 작고, x4는 x1보다 커야한
          다. (b) x4는 x1보다 크지만, x3 이 x2보다 작지 않으므로, 사각형은 겹치지
          않는다. y좌표에 대해서도 같이 적용할 수 있다. 이것은 4번의 if문으로 사
          각형의 겹침 유무를 검사할 수 있다.

  독자들은 사각형의 겹침 유무를 검사하기 위해서 어떤 방법을 사용할 것인
가? 어떤 사람은 Windows GDI함수 같은

PointInRectangle()■;

             실제 GDI 함수의 이름은 PtInRect()이다.

등의 함수를 8번 사용할지도 모른다. 하지만, 위 그림에서처럼, 4개의 if문으로
이것을 검사하는 것이 가능하며, 이것은 가장 빠르다. 한쪽 사각형의
Left-Top의 좌표를 (x1,y1), Right-Bottom의 좌표를 (x2,y2)라 하고, 나머지
한쪽에 대해서도 각각 (x3,y3), (x4,y4)라 하자. 다음의 문장이 참이면, 사각형
은 겹친다.

(x2≥x3) and (x4≥x1) and (y2≥y3) and (y4≥y1)

  2차원 평면상의 한 점을 나타내기 위하여, Vector라는 구조체를 사용하기로
하자.

    struct Vector {

        long x,y;

    };//struct Vector

  사각형이 겹치는 지의 유무를 검사하는 함수를 QuickRejection()이라 하자.
이 함수는 사각형이 겹치면 1을 리턴한다. 함수는 아래와 같다.

#define min(a,b) ((a)<(b)?(a):(b))

#define max(a,b) ((a)>(b)?(a):(b))

int QuickRejection(Vector p1,Vector p2,Vector p3,Vector p4) {

//return 1 if it intersects

    long x1,y1,x2,y2,x3,y3,x4,y4;

    x1=min(p1.x,p2.x);

    y1=min(p1.y,p2.y);

    x2=max(p1.x,p2.x);

    y2=max(p1.y,p2.y);

    x3=min(p3.x,p4.x);

    y3=min(p3.y,p4.y);

    x4=max(p3.x,p4.x);

    y4=max(p3.y,p4.y);

    return (x2>=x3) && (x4>=x1) && (y2>=y3) && (y4>=y1);

}//QuickRejection()

  이것으로 스프라이트 충돌의 모든 것이 준비되었는가? 그렇지 않다. 아래의
그림을 보자.

             바운딩 사각형의 한계: 바운딩 사각형으로는 스프라이트의 충돌을 완전하
          게 검사할 수 없다.

  위의 그림에서 보듯이, 바운딩 사각형으로 충돌을 검사하는 것은 완전한 방
법이 못된다. 이렇게 게임을 만든다면, 사용자는 약간만 가까이 가도, 터져 버
리는 스프라이트에 짜증을 감추지 못할 것이다. 간단한 해결 방법은 사각형의
크기를 줄이는 것이다. 물론, 이 사각형은 스프라이트의 핵심 부분을 포함하는
사각형이라야 한다. 이 사각형을 코어 사각형(core rectangle)이라 하자.

             코어 사각형(core rectangle): 코어 사각형은 스프라이트가 실제로 충돌하는
          부분을 검사하기 위해 사용한다. 이러한 사각형은 스프라이트의 세밀한 부
          분의 충돌을 검사할 수 없다.

  위의 예에서처럼, 바운딩 사각형외에 코어 사각형을 별도로 정의한다. 물론
스프라이트의 움직임을 추적하기 위해서는 바운딩 사각형의 좌표도 유지되어
야 한다. 그렇다면, 코어 사각형이 충돌을 해결하기 위한 완전한 방법이 될 수
있을까?

             코어 사각형의 충돌: 코어 사각형은 구현이 쉽지만, 2차원 격투기 게임 등
          에서와 같은 세밀한 충돌을 감지할 수 없다.

  그렇지 않다. 사각형 자체의 한계로 인해, 정밀한 충돌을 요하는 게임 프로
그램에서는 사용하기 힘들다. 위의 예에서처럼, 코어 사각형의 충돌은 굉장히
많은 부분의 충돌만 식별할 뿐, 세밀한 충돌을 요하는 격투기 게임 등에는 사
용이 어렵다.

  실제 스프라이트의 충돌을 검사하기 위해서는, 스프라이트 자체가 다각형
(polygon)으로 정의되는 것이 바람직하다. 아래의 예를 보자.

             바운딩 다각형(bounding polygon): 스프라이트의 충돌을 검사하기 위해서는
          다각형으로 스프트라이트를 지정해야 한다.

  위의 그림에서처럼, 스프라이트를 둘러싸는 바운딩 다각형(bounding
polygon)을 정의한 다음, 이러한 다각형의 충돌 유무를 검사하는 것이 완전
한 해결책이 될 것이다.

  이런! 다각형의 충돌을 어떻게 검사해야 하는가? 이것은 생각보다 그리 어
렵지 않다. 먼저 해야 하는 것은 선(line)의 충돌을 검사하는 것이다. 다각형이
선의 집합으로 표현되는 것에 주목하라!

  선 충돌의 탐지

  두 개의 선(line)이 충돌하는지에 관한 검사는 계산 기하학의 가장 기초적인
단계이다. 하지만, 선 충돌을 검사하기 위해서 제일 먼저 해야하는 것은 2개의
선 벡터(vector) - 2차원 벡터 - 가 시계 방향(clockwise)인지 반시계 방향
(anticlockwise)인지를 결정하는 것이다.

  기하학 문제를 수학적인 방법으로 접근하는 것은 문제를 어렵게 만든다. 3D
라이브러리가 그렇듯이, 많은 계산 기하학 문제는 행렬(matrix)의 계산을 필요
로 한다.

  2차원 평면상의 점의 위치를 나타내기 위해, 위에서 정의한 Vector■라는 구

             점 (x,y)와 벡터 (x,y)는 무엇이 다른가? 벡터는 크기(scalar)와 방향
          (direction)을 가진다. 벡터 (x,y)의 다른 한 쪽이 원점 (0,0)으로 가정되었다
          면, 벡터 (x,y)의 크기는 sqrt(x2+y2)이며, 방향은 (0,0)에서 (x,y)방향으로의 화
          살표의 진행 방향을 의미한다. 점 (x,y)는 단순히 그 위치 자체이다.

조체를 사용하기로 하자. 그러면 선은 2개의 Vector 구조체로 표현하는 것이
가능하다.

  독자들은 고등학교 정석에서 배웠던 외적(cross product)의 정의를 기억하
는가? 외적의 기하학적인 의미와 공식을 상기해 보자.

             외적(cross product): (a) 벡터 p1과 p2의 외적은 p1×p2로 나타낸다. 이것
          의 결과는 값(scalar)이며, |p1|×|p2|×sin(θ)의 공식으로 구할 수 있다. 여기
          서 θ는 p1과 p2가 이루는 각(p2와 p1이 이루는 각이 아닌)이며, 기하학적
          인 의미는 원점, p1, p2 및 p1+p2를 꼭지점으로 하는, 평행 사변형
          (parallelogram)의 면적이다. (b) 벡터 p와 반 시계 방향(anticlockwise)은 그림
          에서 음영 부분을 의미한다. 이것은 p와의 외적이 양수(>0)인 모든 벡터를
          포함한다.

  2개의 벡터 p1=(x1,y1)과 p2=(x2,y2)에 대해, p1과 p2의 외적은

p1×p2=|p1|×|p2|×sin(θ)=x1×y2-x2×y1

으로 정의한다. 1사분면과 2사분면에서 - 0°와 180°사이에서 - sine의 값은
양수(>0)임을 기억하자. |p1|는 벡터 p1의 크기(sqrt(x12+y12))를 의미하며, θ
는 p1에서 p2로의 각이다(각은 반시계 방향으로 진행할 때 양수임을 주의하
자).

             사분면: 올 산타클로스(all santa claus)란 고등학교 때의 공식을 기억하는
          가? 1,2사분면에서 sine의 값은 양수이다.

  외적 계산은 위의 식에서처럼, sine을 계산하지 않고 2번의 곱셈으로 쉽게
구할 수 있다.

x1×y2-x2×y1

  시계방향(clockwise)과 반시계 방향을 앞 쪽의 그림 (b)처럼 정의하자. p1에
대해 p2가 시계 방향에 위치한지, 반시계 방향에 위치한지 구하기 위해, 외적
을 사용할 수 있다. p1×p2가 양수(>0)라면 p2는 p1에 대해 반시계 방향에 위
치한다(sine의 값이 양수이므로). 그렇지 않다면 p2는 p1에 대해 시계 방향에
위치한다. 물론 0°와 180°인 경우는 외적의 결과는 0일 것이다. 외적을 구하
는 함수 CrossProduct()를 설계해 보자.

             방향의 결정: (a) p1×p2 > 0은 p2가 p1에 대해 반시계 방향에 있다는 것
          을 의미한다. (b) p1×p2 < 0은 p2가 p1에 대해 시계 방향에 있다는 것을
          의미한다.

struct Vector {

    int x,y;

};//struct Vector

int CrossProduct(int x1,int y1,int x2,int y2) {

    return x1*y2-x2*y1;

}//CrossProduct()

int CrossProduct(Vector v1,Vector v2) {

    return CrossProduct(v1.x,v1.y,v2.x,v2.y);

}//CrossProduct()

  함수 CrossProduct(v1,v2)의 결과가 >0이면, v2는 v1에 대해 반시계 방향에
있다(좀더 엄격히 말하면, 선 (0,0)-(x2,y2)는 선 (0,0)-(x1,y1)의 반시계 방향
에 있다).

  아래에 예제 프로그램을 리스트하였다. 아래의 예에서 Difference(v1,v2)는
v1-v2를 계산한다. 이 함수는 후에 사용할 것이다.

#include <stdio.h>

struct Vector {

    int x,y;

};//struct Vector

int CrossProduct(int x1,int y1,int x2,int y2) {

    return x1*y2-x2*y1;

}//CrossProduct()

int CrossProduct(Vector v1,Vector v2) {

    return CrossProduct(v1.x,v1.y,v2.x,v2.y);

}//CrossProduct()

Vector Difference(Vector v1,Vector v2) {

//Calculate v1-v2

    Vector t;

    t.x=v1.x-v2.x;

    t.y=v1.y-v2.y;

    return t;

}//Difference()

void main() {

    Vector o={0,0},v1={1,5},v2={5,5};

    int i;

    i=CrossProduct(v1,v2);

    if (i>0) printf("v1 is Clockwise from v2\n");

    else if (i==0) printf("v1 and v2 are collinear\n");

    else if (i<0) printf("v1 is Anticlockwise from v2\n");

}//main()

  결과는

v1 is Anticlockwise from v2

이다(그림을 그려 보라).

  위의 예에서 우리는 선의 한 끝을 원점으로 고정시킨 선에 대해서만 생각해
보았다. 임의의 두 점을 선의 양 끝점으로 가지는 선에 대해서는 어떻게 계산
할 것인가?

  그렇다. 평행 이동이다. 선의 한 점을 원점으로 평행이동 한 벡터를 구한 후
외적을 계산하는 것이다. 벡터의 가감(add and/or subtraction)은 벡터를 이루
는 각각의 요소를 가감한다는 사실을 기억하라. 벡터의 차를 구하기 위해 우
리는 다음과 같은 Difference()함수를 설계할 수 있다.

Vector Difference(Vector v1,Vector v2) {

//Calculate v1-v2

    Vector t;

    t.x=v1.x-v2.x;

    t.y=v1.y-v2.y;

    return t;

}//Difference()

  위 함수는 v1-v2를 계산한다. 이제 (2,2)와 (1,5)를 끝점으로 하는 선과,
(2,2)와 (5,5)를 끝점으로 하는 선의 방향을 결정해 보자. 각각을 v1, v2라고
하면, v2는 분명 v1에 대해 시계 방향에 있다. 2선 모두를 (-2,-2)만큼 평행이
동하면, v1=(-1,3), v2=(3,3)이 될 것이다. 이것을 CrossProduct()의 파라미터로
전달하면 된다.

  아래에 프로그램을 리스트하였다.

#include <stdio.h>

struct Vector {

    int x,y;

};//struct Vector

int CrossProduct(int x1,int y1,int x2,int y2) {

    return x1*y2-x2*y1;

}//CrossProduct()

int CrossProduct(Vector v1,Vector v2) {

//Calculate |v1|*|v2|*sin(theta)

    return CrossProduct(v1.x,v1.y,v2.x,v2.y);

}//CrossProduct()

Vector Difference(Vector v1,Vector v2) {

//Calculate v1-v2

    Vector t;

    t.x=v1.x-v2.x;

    t.y=v1.y-v2.y;

    return t;

}//Difference()

void main() {

    Vector o={2,2},v1={1,5},v2={5,5};

    int i;

    i=CrossProduct(Difference(v2,o),Difference(v1,o));

    if (i>0) printf("Clockwise\n");

    else if (i==0) printf("Collinear\n");

    else if (i<0) printf("Anticlockwise\n");

}//main()

결과는

Clockwise

이다(그림을 그려 보라).

  이제 두 선의 겹침 유무를 코딩할 준비가 모두 끝났다. 두 선이 겹치는 경
우의 각각에 대해서 그림을 그려보면 아래와 같다. 각각의 선은 p1→p2, p3→
p4를 양 끝점으로 가진다.

             선이 겹치는 경우: (a) 겹친다면 p1→p2를 경계로 p3과 p4가 나뉘므로, p1
          →p2를 기준으로 방향을 검사한다. (b) 겹치지 않는다면 p3과 p4는 모두 한
          쪽에 있다. (c) 1점에서 만나는 경우 특별한 고려가 필요하다. (d) 일치하는
          경우, 즉 벡터의 방향이 같은 경우 역시 특별한 고려가 필요하다. (e) 만나
          지 않는다면 QuickRejection() 검사에서 제외한다.

  먼저 두 선이 확실하게 겹치는 (a)를 고려해 보자. p1→p2를 기준으로 다른
한 선의 끝점 p3,p4는 분리된다. 만약 p1→p2에 대해 p1→p3이 반시계 방향에
있다면, p1→p4는 시계 방향에 있어야 한다. 이 사실은 반대 방향에 대해서도
성립한다. 그러므로, 겹침의 유무를 결정하기 위해, (p2-p1)×(p3-p1)과
(p2-p1)×(p4-p1)를 구한 다음, 부호가 반대이면(2개의 외적을 곱한 결과가 음
수이면) 2선은 확실히 겹친다. 이것을 함수로 구현해 보자.

int IsIntersect(Vector p1,Vector p2,Vector p3,Vector p4) {

    if (!QuickRejection(p1,p2,p3,p4)) return 0;

    int i=CrossProduct(Difference(p3,p1),Difference(p2,p1));

    int j=CrossProduct(Difference(p4,p1),Difference(p2,p1));

    return (i < 0 && j > 0) || (i > 0 && j < 0);

}//IsIntersect()

  IsIntersect(p1,p2,p3,p4)는 먼저 QuickRejection()을 호출한다.
QuickRejection()이 실패하면, 외적의 부호를 검사한다. p1→p2와 p3→p4가 만
나면 IsIntersect()는 1을 리턴한다.

  소스를 아래에 리스트하였다.

#include <stdio.h>

#define min(a,b) ((a)<(b)?(a):(b))

#define max(a,b) ((a)>(b)?(a):(b))

struct Vector {

    int x,y;

};//struct Vector

int CrossProduct(int x1,int y1,int x2,int y2) {

    return x1*y2-x2*y1;

}//CrossProduct()

int CrossProduct(Vector v1,Vector v2) {

//Calculate |v1|*|v2|*sin(theta)

    int i=CrossProduct(v1.x,v1.y,v2.x,v2.y);

    return i;

}//CrossProduct()

Vector Difference(Vector v1,Vector v2) {

//Calculate v1-v2

    Vector t;

    t.x=v1.x-v2.x;

    t.y=v1.y-v2.y;

    return t;

}//Difference()

int QuickRejection(Vector p1,Vector p2,Vector p3,Vector p4) {

//return 1 if it intersects

    int x1,y1,x2,y2,x3,y3,x4,y4;

    x1=min(p1.x,p2.x);

    y1=min(p1.y,p2.y);

    x2=max(p1.x,p2.x);

    y2=max(p1.y,p2.y);

    x3=min(p3.x,p4.x);

    y3=min(p3.y,p4.y);

    x4=max(p3.x,p4.x);

    y4=max(p3.y,p4.y);

    return (x2>=x3) && (x4>=x1) && (y2>=y3) && (y4>=y1);

}//QuickRejection()

int IsIntersect(Vector p1,Vector p2,Vector p3,Vector p4) {

    if (!QuickRejection(p1,p2,p3,p4)) return 0;

    int i=CrossProduct(Difference(p3,p1),Difference(p2,p1));

    int j=CrossProduct(Difference(p4,p1),Difference(p2,p1));

    return (i < 0 && j > 0) || (i > 0 && j < 0);

}//IsIntersect()

void main() {

    Vector p1={2,2},p2={5,5},p3={5,1},p4={5,1};

    if (IsIntersect(p1,p2,p3,p4))

        printf("Intersect\n");

    else

        printf("Not intersect\n");

}//main()

결과는

Intersect

이다.

  선 충돌 애니메이션

  우리가 앞 장에서 'XOR 애니메이션'을 하면서 만들었던 CPoint와 CLine
클래스를 기억하는가? 이것은 그래픽 화면에 움직이면서 튕기는 직선을 구현
하기 위해 만든 클래스였다.

  이번에는 이 프로그램를 조금 수정해 2개의 직선이 화면에서 움직이도록 해
보자. 그리고 2개의 직선이 서로 충돌하면 직선의 진행 방향은 바뀌게 된다.

  바뀐 부분은 소스에 주석으로 설명하였다.

#include <stdio.h>

#include <conio.h>

#include <dos.h>

#include <graphics.h>

#define min(a,b) ((a)<(b)?(a):(b))

#define max(a,b) ((a)>(b)?(a):(b))

struct Vector {

    long x,y;

};//struct Vector

long CrossProduct(long x1,long y1,long x2,long y2) {

//좌표는 오버플로우를 방지하기 위해 long으로 선언한다.

    return x1*y2-x2*y1;

}//CrossProduct()

long CrossProduct(Vector v1,Vector v2) {

//Calculate |v1|*|v2|*sin(theta)

    return CrossProduct(v1.x,v1.y,v2.x,v2.y);

}//CrossProduct()

Vector Difference(Vector v1,Vector v2) {

//Calculate v1-v2

    Vector t;

    t.x=v1.x-v2.x;

    t.y=v1.y-v2.y;

    return t;

}//Difference()

int QuickRejection(Vector p1,Vector p2,Vector p3,Vector p4) {

//return 1 if it intersects

    long x1,y1,x2,y2,x3,y3,x4,y4;

    x1=min(p1.x,p2.x);//mix, max매크로를 사용하지 않으면 비교횟수를 줄일

                      //수 있다. 시도해 보라.

    y1=min(p1.y,p2.y);

    x2=max(p1.x,p2.x);

    y2=max(p1.y,p2.y);

    x3=min(p3.x,p4.x);

    y3=min(p3.y,p4.y);

    x4=max(p3.x,p4.x);

    y4=max(p3.y,p4.y);

    return (x2>=x3) && (x4>=x1) && (y2>=y3) && (y4>=y1);

}//QuickRejection()

int IsIntersect(Vector p1,Vector p2,Vector p3,Vector p4) {

    if (!QuickRejection(p1,p2,p3,p4)) return 0;

      //QuickRejection() 테스트가 없다면 어떤 문제가 발생하는가?

    long i=CrossProduct(Difference(p3,p1),Difference(p2,p1));

    long j=CrossProduct(Difference(p4,p1),Difference(p2,p1));

    return (i < 0 && j > 0) || (i > 0 && j < 0);

}//IsIntersect()

class CPoint {

    Vector p;//int x,y는 이제 Vector p로 선언하였다.

    int dx,dy;

  public:

    CPoint(int tx,int ty) {

        p.x=tx; p.y=ty;

        dx=dy=1;

    }//CPoint

    int GetX() { return p.x; }

    int GetY() { return p.y; }

    void Move();

    Vector GetVector() { return p; }

    void ToggleDir() { dx=-dx; dy=-dy; }//진행 방향을 토글한다.

};//class CPoint

void CPoint::Move() {

    if (p.x==0 || p.x==639) dx=-dx;//meet y-axis

    if (p.y==0 || p.y==479) dy=-dy;//meet x-axis

    p.x+=dx; p.y+=dy;

}//CPoint::Move

class CLine {

  public:

    CPoint *p[2];//선은 2개의 벡터로 구성한다.

  private:

    void DrawALine() {

        line(p[0]->GetX(),p[0]->GetY(),

             p[1]->GetX(),p[1]->GetY());

    }//DrawALine

  public:

    CLine(int x1,int y1,int x2,int y2);

      //생성자는 선의 양 끝점을 설정하도록 수정하였다.

    ~CLine();

    void Draw();

    void ToggleDir(){//선의 진행 방향을 토글한다.

        p[0]->ToggleDir();

        p[1]->ToggleDir();

    }

};//class CLine

CLine::CLine(int x1,int y1,int x2,int y2) {

    p[0]=new CPoint(x1,y1);

    p[1]=new CPoint(x2,y2);

    DrawALine();//draw a line

}//CLine::CLine

CLine::~CLine() {

    delete p[0];

    delete p[1];

}//CLine::~CLine

void CLine::Draw() {

    DrawALine();//erase previous line

    p[0]->Move();

    p[1]->Move();

    DrawALine();//draw a line

}//CLine::Draw

void main() {

    int gd=0,gm;

    initgraph(&gd,&gm,"..\\..\\bgi");

    setwritemode(XOR_PUT);//set eXclusive Or write mode

    CLine line[2]={CLine(100,300,300,10),CLine(600,300,500,400)};

    while (!kbhit()) {

        line[0].Draw();

        line[1].Draw();

        if (IsIntersect(line[0].p[0]->GetVector(),

                        line[0].p[1]->GetVector(),

                        line[1].p[0]->GetVector(),

                        line[1].p[1]->GetVector())

        ) {

          //선이 만나면 선의 진행 방향을 토글한다.

            line[0].ToggleDir();

            line[1].ToggleDir();

        }//if

        delay(10);

    }//while

    getch();

    closegraph();

}//main

  아래의 그림은 실행 결과를 캡쳐(capture)한 것이다. 선 2개가 만나기 전의
모양이다. 선이 만나면 진행 방향은 토글된다.

             선 충돌 애니메이션의 실행화면: 두 개의 선은 화면의 가장 자리에서 튕
          기면서 움직인다. 두 개의 선이 만나면 선은 현 진행 방향의 반대로 움직
          인다.

   최종 목표: 다각형의 충돌

  우리의 최종 목표를 기억하는가? 그것은 다각형의 충돌을 검사하는 것이었
다. 80/80 규칙에 따라 이것은 연습 문제에 제시한다.

  연습문제

1. IsIntersect()는 한 점에서 만나는 경우와 방향이 일치하는 경우에 대해서
완벽하게 동작하지 않는다. 함수를 수정하라.

2. CrossProduct()가 정수(int)를 파라미터로 받아 정수를 리턴한다면 어떤 논
리적 오류가 발생할 수 있는가?(힌트: 운영체제가 16비트라면 int는 16비트이
다)

3. 각각 m,n개의 점으로 구성된 2개의 다각형의 충돌을 감지하는 프로그램을
작성하라. 먼저, 독자들은 임의의 점 n으로 구성된 다각형을 구성하는 클래스
를 작성해야 한다.

4. 1에서 작성한 프로그램의 속도를 얼마나 향상시킬 수 있는가?
O(max(m,n))에 동작하는 개선된 프로그램을 작성하라.

5. 사각형의 충돌 여부를 검사하는 QuickRejection()을 다각형의 충돌에 어떻
게 사용할 수 있는가? 자신의 아이디어를 제시하고, 속도를 최적화
(optimization)하라.

6.


40. 패턴 매칭

40. 패턴 매칭(pattern
matching)

  패턴 매칭이 1차원인 경우는 바로 스트링 매칭(string matching) 문제이
다. 이것은 'Z-알고리즘'을 참조하라. 여기서는 2차원 패턴 매칭 문제를 다룬
다. 그리고 이 방법을 컴퓨터 바둑(computer Go)의 패턴 매칭에 응용해 본다.

  우리가 사용할 방법은 Rabin과 Karp에 의해 소개된 방법이다. 이 알고리즘
은 스트링 매칭 문제를 해결하기 위해 소개된 방법이지만, 2차원 매칭 문제에
간단히 적용될 수 있다. 그러기 위해서는 먼저 원래의 Rabin-Karp의 스트링
매칭 알고리즘■을 이해하는 것이 필요하다.

             이것을 'Rabin-Karp 알고리즘'이라고 해서는 안 된다. 'Rabin-Karp의 스트
          링 매칭 알고리즘'이라 해야 한다. 이 두 사람은 스트링 매칭 알고리즘 외
          에도 많은 알고리즘을 발표했다.

용어 정의

  텍스트(text)는 길이가 n인 일차원 배열 T[1..n■]이다. 패턴(pattern)은 길

             일반적으로 문자의 인덱스는 1부터 시작한다. 하지만, C++에서 배열로 문
          자열을 다룰 경우, 인덱스는 0부터 시작한다. 구현시에 이 부분을 주의해
          야 한다.

이가 m인 일차원 배열 P[1..m]이다. 그리고 P와 T를 구성하는 문자
(character)는 유한한 알파벳(finite alphabet) ∑■의 원소여야 한다. P와 T는
문자열

             ∑는 일반적으로, 형식 언어 이론(formal language theory)에서 언어를 구
          성하는 문자 집합(character set)을 나타내기 위해 사용한다. 독자들은 ∑
          집합의 모든 원소가 ASCII 문자라고 생각하면 된다. 하지만 엄격히 말하면,
          특수문자는 스트링를 구성하지 않는다고 보아야하므로, ASCII에서 제어 문
          자(control character)를 제외한 32-255 사이의 문자라고 보아야 할 것이다.
          한글도 스트링을 구성할 수 있으므로, 128-255사이의 문자도 포함해야 한
          다. 하지만, 이 장에서는 ∑가 영어의 알파벳 대소문자라고 가정할 것이다.
          또한 유한(finite)이라는 가정은 반드시 필요하다. 무한(infinite) 개의 문자로
          구성되어지는 스트링을 비교하기 위해서는 n2의 시간이 필요하지만, 효율
          적인 알고리즘을 구성할 수는 없다.

(string)이라고도 부른다.

  T[s+1..s+m]과 P[1..m]이 매칭될 때, P가 T에서 s+1위치에서 발생(occur)
한다고 하거나, 혹은 패턴 P가 T에서 s번의 이동(shift s)후에 발생한다고
한다. 이러한 s를 타당한 이동(valid shift)이라고 하고, 그렇지 아니한 s를
타당하지 않은 이동(invalid shift)이라고 한다. 그러므로 패턴 매칭 문제는
모든 타당한 이동 s를 찾는 문제라 할 수 있다.

  Rabin-Karp의 스트링 매칭 알고리즘■

             Thomas H. Cormen, Charles E. Leiserson and Ronald L. Rivest,
          "Introduction to Algorithms," McGrow-Hill Book Company, 1992, pp.857-861.

  Rabin과 Karp는 실용적이면서도 이차원 패턴 매칭(two-dimensional
pattern matching)과 같은 연관된 문제에 대해서 잘 수행되는 스트링 매칭 알
고리즘을 제안했다. Rabin-Karp 알고리즘의 최악의 경우 수행 시간
(worst-case running time)은 O((n-m+1)m)이지만, 평균 수행 시간
(average-case running time)은 이보다 훨(better)■ 좋다.

             훨=훨씬, 넘=너무 등은 통신에서 네티즌(netizen)들이 사용하는 은어
          (enigma)이다. 일부에서는 이러한 은어가 국어의 정서를 해친다는 의견이
          있지만, 필자는 개인적으로 이러한 귀여운 단어를 좋아한다.

  이 알고리즘은 기본적인 수(number)에 관한 이론 - 어떤 두 수가 같다면,
그 두 수를 임의의 수로 나눈 나머지도 같다 - 을 사용한다.

  설명을 위한 목적으로, ∑={0,1,2,...,9}라 가정하자. 즉 각 문자는 십진 문자
(decimal character)이다. 일반적으로, 각각의 문자가, d=|∑| 인 기수
d(radix-d)■ 표현에서의 숫자라고 가정한다. 그렇다면, k개의 연속된 문자의

             이진수의 기수는 2, 16진수의 기수는 16이다.

스트링은 길이가 k인 십진수를 표현한다고 볼 수 있다. 그러므로 문자 스트링
"31415"는 십진수 31,415에 해당한다.

  패턴 P[1..m]이 주어졌을 때, p가 해당하는 십진 값을 나타낸다고 가정하자.
비슷한 방법으로 T[1..n]이 주어졌을 때, ts는 길이가 m인 서브스트링
(substring) T[s+1..s+m]의 십진 값을 나타낸다. 여기서 s=0,1,...,n-m이다. 확실
히 ts=p iff■ T[s+1..s+m]=P[1..m]이다. 그러므로 s가 valid shift인 것은 ts=p의

             iff는 if and only if의 약자로 필요충분조건을 나타낸다. p,q가 명제
          (preposition)일 때, p iff q는 if p, then q 그리고 if q, then p의 두 개의 조건
          을 동시에 만족함을 의미한다.

필요충분조건(s is valid shift if and only if ts=p)이다. 우리가 p를 O(m)만에
계산할 수 있고, 모든 ti값들을 전체 시간 O(n)만에 구할 수 있다면, p를 각각
의 ts와 비교함으로써 O(n) 시간에 모든 valid shift를 구할 수 있다.(당분간은
p와 ts가 굉장히 큰 수인 경우의 가능성에 대해서는 걱정하지 말자.)

  우리는 호너의 규칙(Horner's rule)■을 이용하여 아래 식처럼, p를 O(m)
시간에 계산할 수 있다.

             n차 방정식을 평가하기 위해, 보다 적은 수의 곱셈을 사용하는 방법으로
          호너의 규칙은 많이 알려져 있다.

A(x)= SUM from { { j}=0} to n-1 a sub j x sup j

 와 같은 다항식이 주어
          졌을 경우, x=x0일 경우의 다항식의 값을 평가한다면,
          A(x0)=a0+x0(a1+x0(a2+...+x0(an-2+x0(an-1))...)) 처럼 식을 평가하는 것이다. 이것은
          0부터 n-1까지의 for문, 즉 n번의 곱셈만을 필요로 한다. 호너의 규칙을 사
          용하지 않는다면, O(n2)의 곱셈이 필요하다.

p=P[m]+10(P[m-1]+10(P[m-2]+...+10(P[2]+10P[1])...))

  t0값 또한 비슷한 방법으로 T[1..m]으로부터 O(m) 시간에 구할 수 있다.

  t1,t2,...,tn-m의 나머지 값들을 O(n-m)시간에 구하기 위해, 아래 식처럼, ts+1은
ts로부터 상수 시간(constant time)에 구할 수 있다는 것을 살펴보는 것만으로
충분하다.

ts+1=10(ts-10m-1T[s+1])+T[s+m+1] ----- (식1)

  예를 들면, m=5이고 ts=31415일 때, 최상위 숫자 T[s+1]=3을 제거하려 한
다. 새로 추가될 다음 숫자가 T[s+5+1]=2라면,

ts+1=10(31415-104て3)+2

   =14152

이다. 10m-1T[s+1]을 빼는 것은 ts에서 높은 쪽 숫자를 제거하고, 남은 결과에
10을 곱하는 것은 숫자를 왼쪽으로 한 칸 이동(shift)■하고, T[s+m+1]을 더하

             2진수의 shift에서 i<<1은 i*2와 같다. 즉 n진수의 왼쪽으로의 m번 shift는
          nm을 곱한것과 같다.

는 것은 낮은 쪽 숫자를 설정하는 것이다. 만약 상수 10m-1이 미리 계산되었다
면, (식1)의 각각의 계산은 상수의 수학 연산만이 필요하다. 그러므로 p와
t0,t1,...,tn-m은 모두 O(n+m) 시간에 계산할 수 있다. 그리고 우리는 텍스트
T[1..n]에서 패턴 P[1..m]을 O(n+m) 시간에 모두 찾을 수 있다.

  이 방법의 유일한 난점은 p와 ts가 편안히 다루기에는 너무 클 수 있다는
것이다. 만약 P가 m개의 문자를 포함한다면, 숫자의 길이가 m인 p에 가하는
수학 연산이 "상수 시간"이라는 가정은 타당하지 않다. 다행히, 이 문제에 대
한 간단한 해결책이 있다. 즉, 적당한 계수(modulus) q에 대해, p와 ts의 모듈
로(modulo) 계산을 한다. 이것이 아래 그림에 나타나있다.

             Rabin-Karp 알고리즘: 각각의 문자는 십진 숫자이며, 모듈로 13(modulo
          13) 연산을 한다. (a) 텍스트 스트링. 길이 5인 윈도우가 굵은 글씨로 나타
          나 있다. 굵은 글씨의 숫자 값은 modulo 13연산을 취해, 7이다. (b) 길이가
          5인 모든 윈도우에 대해 값이 구해진 같은 텍스트 스트링. 윈도우의 값이
          7인 경우가 2곳 있지만, 한 곳은 가짜 적중(spurious hit)이다. (c) 이전 윈도
          우의 값이 주어져 있다면, 윈도우의 값을 계산하는 것은 상수 시간이 필요
          하다. 31415의 높은 자리 숫자(high-order digit) 3을 버리고, 1415를 왼쪽으
          로 shift - 곱하기 10 - 한다. 낮은 자리 숫자(low-order digit) 2를 더하여,
          새로운 값은 14152가 된다. 하지만, 모든 계산은 modulo 13이 취해진다. 그
          래서 첫 번째 윈도우의 값은 7이고 새로운 윈도우의 값은 8이다.

  p, ts 그리고 점화식(recurrence) (식1)의 계산은 모듈로 q에 대해 수행될 수
있으므로, 우리는 p와 모든 ts의 모든 모듈로 계산이 O(n+m) 시간에 계산됨을
안다. 계수 q는 전형적으로, 10q가 컴퓨터 워드(computer word)에 부합되는
소수(prime)를 고른다. 이렇게 하는 것이 모든 필요한 계산이 단일 정밀도 계
산(single-precision arithmetic)만으로 되도록 한다. 일반적으로 d-진수 알파벳
{0,1,...,d-1}에 대해, dてq가 컴퓨터 워드에 맞도록 q를 선택하며, (식1)이 모듈
로 q로 동작하도록 식을 정돈한다. 그러므로 다음과 같이 된다.

ts+1=(d(ts-T[s+1]h)+T[s+m+1]) mod■ q ------ (식2)

             C++에서 모듈로 연산자는 %이다. 하지만, 일반적인 알고리즘을 기술할
          때는 mod를 사용한다. 이것은 전통적으로 알고리즘을 기술하는 언어가 파
          스칼(pascal) 때문이기 때문이며, 파스칼은 mod를 키워드로 제공한다.

여기서 h≡dm-1(mod q), 즉, m-자리 텍스트 윈도우에서 가장 높은 위치에 "1"
이 있을 때의 값이다.

  그러나 모듈로(modulo) q로 작업하는 방법에는 결점(fly)이 있다. 왜냐하면,
ts≡p (mod q)가 ts=p를 의미하지 않기 때문이다. 반면에, 만약 ts≠p (mod q)
이면, ts≠p인 것은 확실하다. 그러므로 그러한 shift s는 타당하지 않다
(invalid). 이처럼 우리는 invalid shift를 발견하기 위해서, ts≡p (mod q)를 빠
른 휴리스틱 테스트로 이용할 수 있다. 만약 ts≡p (mod q)가 만족되면, s가
정말 valid한지를 검사하기 위해서, 실제의 테스트 - P[1..m] = T[s+1..s+m] -
를 수행한다. 만약 q가 충분히 크다면, 여분의 검사를 위한 비용이 충분히 낮
게, 가짜 적중(spurious hit)이 일어나도록 할 수 있다.

  아래의 함수가 이러한 아이디어를 정확하게 기술하고 있다. 함수의 입력은
텍스트 T, 패턴 P, 기수 d(radix d) - 일반적으로 |∑|를 취한다 - 와 소수
q(prime q)이다.

Rabin-Karp-Matcher(T,P,d,q)

 1  n=length(T)

 2  m=length(P)

 3  h=dm-1 mod q

 4  p=0

 5  t0=0

 6  for i=1 to m do begin

 7      p=(dてp+P[i]) mod q

 8      t0=(dてt0+T[i]) mod q

    end

 9  for s=0 to n-m do begin

10      if p=ts then

11          if P[1..m]=T[s+1..s+m] then

12              "Pattern occurs with shift" s

13      if s<n-m then

14          ts+1=(d(ts-T[s+1]h)+T[s+m+1]) mod q

    end

  Rabin-Karp-Matcher 함수는 다음과 같이 동작한다. 모든 숫자는 기수-d의
숫자로 번역된다. t의 아래첨자(subscript)는 명확성을 기하기 위해서 적었다.
아래첨자가 없어도 알고리즘은 정확히 동작한다. 3번 라인이 m자리 숫자의
최상위 숫자의 값으로 h를 초기화한다. 4-8번 라인이 P[1..m] mod q의 값으
로 p를 계산하고, T[1..m] mod q의 값으로 t0를 계산한다. 9번 라인부터 시작
하는 for 루프는 모든 가능한 shift s를 검사한다. 루프는 다음과 같은 루프
불변조항(loop invariant)을 가진다. 10번 라인이 실행될 때는 언제나,
ts=T[s+1..s+m] mod q이다. 만약 10번 라인에서 p=ts이면, 가짜 적중을 배제
(rule out)하기 위해, 11번 라인에서 P[1..m]=T[s+1..s+m]을 검사한다. 발견된
모든 valid shift는 12번 라인에서 출력된다. 13번 라인에서 검사하듯이, 만약
s < n-m이면, for 루프는 최소한 1번 이상 실행된다. 그리고 라인 10번이 다
시 실행될 때 불변조항이 성립하는 것을 보장하기 위해, 14번 라인이 실행된
다. 14번 라인은 ts mod q로부터 ts+1 mod q를 (식2)를 그대로 사용하여 상수
시간에 계산한다.

  Rabin-Karp-Matcher 함수의 실행 시간은 최악의 경우 Θ((n-m+1)m)이다.
왜냐하면 알고리즘이 valid shift인지를 매번 검증(verification)하기 때문이다.
만약 P=am이고 T=an이라면, 검증 시간은 Θ((n-m+1)m)이다. 왜냐하면, 모든
n-m+1의 shift가 valid하기 때문이다.

  많은 응용에서 우리는 보다 적은 - 아마도 O(1)정도 되는 - valid shift를
기대한다. 그래서 알고리즘의 기대되는 수행 시간은 O(n+m)에 가짜 적중을
처리하는데 필요한 시간을 더한 시간일 것이다. 모듈로 q값으로 줄이는 과정
을 ∑*에서 Zq로의 임의 사상(random mapping)처럼 가정하여, 휴리스틱 분석
의 기초를 세울 수 있다.(실제로 모듈로 q 연산은 해싱 함수의 역할을 한다)

  가짜 적중이 O(n/q)인 것을 기대할 수 있다. 왜냐하면 임의의 ts가 p - 즉
modulo q - 와 같아질 확률은 약 1/q이기 때문이다. 그러므로 Rabin-Karp 알
고리즘의 기대되는 시간은

O(n)+O(m(v+n/q))

이다. 여기서 v는 valid shift의 수이다. 이 수행시간은 q ≥ m되는 q를 고르
면, O(n)이다. 다시 말하면, 예상되는 valid shift의 수가 적고, 소수 q가 패턴
의 길이에 비해 충분히 크다면, Rabin-Karp 알고리즘이 O(n+m) 시간에 수행
되는 것을 기대할 수 있다.

  2차원 패턴 매칭

  연습문제

1. Rabin-Karp의 알고리즘이 3차원 매칭 문제에는 어떻게 적용될 수 있는가?
방법을 구체적으로 기술하라.

2.


42. BGI와 GDI의 비교

  비로 이 책에서는 대부분의 그래픽을 BGI(borland graphic interface)를
사용했음에도 불구하고, 독자들은 GDI(graphic device interface)를 사용해
서 프로그램을 개발할 경우가 훨씬 많을 것이다.

  BGI는 볼런드(borland) - 지금은 인프라이즈(inprise) - 계열의 컴파일러에
서만 동작하는 도스(DOS)와 C를 위한 그래픽 라이브러리이다. 하지만, GDI는
윈도우 운영체제의 표준 그래픽 라이브러리로 모든 컴파일러와 모든 언어에서
사용 가능하다.

  먼저 그래픽 라이브러리가 지원해야 하는 기능들을 살펴보고 용어를 정의한
다음, 이러한 기능들이 BGI와 GDI에서 어떻게 지원되는지 살펴 볼 것이다.
그리고 BGI함수에 대응하는 GDI함수를 살펴 볼 것이다. 또한 GDI에만 있는
함수와 개념들도 살펴 볼 것이다.

  개념 및 용어 정의

  아래와 같은 그림을 그리는 작업을 생각해 보자. 아래의 그림은 화면
(screen) 혹은 윈도우(window)에 사선(diagonal line)을 무늬로 하는 사각형
(rectangle)을 그린 다음, 이 사각형의 일부를 다른 위치로 복사(copy)하여 얻
어진 그림이다.

             사각형을 그린 다음 사각형의 일부를 복사한 그림이다. 복사 대상 영역
          (region)이 원으로 지정되어 있어서 오른쪽 아래 부분에는 사각형의 일부만
          이 나타나 있다.

  먼저 좌측 상단의 사각형을 그리는 작업을 살펴보자.

             사각형 그리기: 사각형의 좌표 (left,top)-(right,bottom)가 명시되어야 한다.
          물론 이 좌표의 좌표계(coordinate system)와 맵핑 모드(mapping mode)가
          명시되어 있어야 할 것이다. 또한 사각형을 둘러 싼 선의 색(line color)과
          선의 모양(line style)을 명시해야 하며, 사각형 내부를 칠하는 브러쉬(brush)
          의 색과 모양(pattern)을 명시해야 한다. 또한 간과하기 쉬운 한 가지는, 선
          이나 면을 그릴 때, 배경과의 연산(operation)이다. 선의 색이 검은색이고,
          배경 색이 초록이라면, 그려진 선의 색은 무엇이 되어야 하는가?

  사각형을 그리기 위해서는 4개의 좌표 - 좌측 상단의 점(point)과 우측 하
단의 점 - 가 명시되어야 한다. 사각형을 그리는 함수의 이름이 Rectangle()
이라면, 함수의 호출은

Rectangle(left,top,right,bottom);

이 될 것이다. 하지만, 함수의 호출 전에 결정되어야 하는 것이 있다. 좌표계
(coordinate system)와 맵핑 모드(mapping mode)가 그것이다. 일반적으로
우리가 알고 있는 좌표계는 데카르트 좌표계(Decarte coordinate system) - x
의 값은 오른쪽으로 증가하고, y의 값은 위쪽으로 증가하는 - 이지만, 컴퓨터
에서 사용하는 좌표계는 일반적으로 화면 좌표계(screen coordinate system)
이다. 이것은 x값이 오른쪽으로 증가하고, y값은 아래로 증가한다. 그리고 원
점은 화면의 좌측 상단이 된다. 그리고 각 좌표의 단위가 결정되어야 한다. 일
반적으로 화면의 한 점에 대응하는 pixel이 기본 단위가 되는 경우가 많지만,
mm, inch 와 point등이 단위가 될 수 있다. 맵핑 모드를 결정하는 함수를
SetMapMode()라고 하고, 좌표계의 원점을 설정하는 함수를
SetWindowOrigin()이라고 하면, 함수의 호출은

SetMapMode(MM_TEXT);

SetWindowOrigin(100,100);

  이 될 것이다. 이것은 픽셀 단위의 좌표를 사용하며, (100,100)을 새로운 원
점으로 설정한다.

  또한 사각형을 그리기 전에 설정되어야 하는 것은 사각형을 구성하는 선의
모양과 면의 모양■이다. 각각의 함수를 SelectLineStyle(), SelectBrushStyle()

             사각형을 그리는 함수를 그리기 함수(drawing function)라고 하면, 선과
          면의 모양을 결정하는 함수는 그리기에 필요한 속성(attribute)을 설정하는
          함수이다. 일반적으로 그래픽 라이브러리는 그리기 함수와 연관된 속성을
          설정(set)하거나 얻어오는(get) 함수를 제공한다.

이라고 하면, 함수의 호출은

    SelectLineStyle(BLACK, SOLID_LINE, THICK_WIDTH);

    SelectLineStyle(GREEN, DIAGONAL_BRUSH);

처럼 될 것이다. 이것은 굵은 검은색 실선을 선택하며, 사각형의 면을 채우기
위해 사선 모양의 초록색 패턴 브러쉬를 선택한다.

  여기서 주의해야 할 한 가지는 배경에 이미 그림이 그려져 있는 경우이다.
배경이 이미 빨간색으로 칠해진 경우, 초록색 선은 어떻게 그려져야 하는가?
이것을 결정하는 것이 그리기 모드(drawing mode)이다. 이것은 전경과 배
경 그림의 비트 단위의 연산(bit operation)으로 처리되며, 이것을 이진 래스
터 연산(binary raster operation)이라 한다. 이것을 결정하는 함수를
SetROP2()라고 하면, 함수의 모양은

SetROP2(R2_COPYPEN);

처럼 될 것이다. 이 함수는 배경색과는 상관없이 현재의 펜 색을 그대로 사용
하여 그릴 것을 지시한다.

  지금까지 살펴 본 바로는, 그래픽 라이브러리가 지원해야 하는 기능에는

    ■ 좌표계 및 맵핑 모드를 설정하는 함수

    ■ 그리기 함수

    ■ 그리기 속성을 설정하거나 얻는 함수

    ■ 그리기 모드(raster operation)를 설정하는 함수

가 있다. 또한 특정한 윈도우에 그래픽을 출력하거나, 그래픽 모드에서 그래픽
을 출력하기 위해서는

    ■ 그리기 모드를 열거(open)나 닫는(close) 함수

가 필요할 것이다.

  BGI에서 그리기 모드를 연다는 것은 텍스트 모드■를 그래픽 모드로 바꾸는

             IBM PC는 텍스트 모드에서 시작한다. 하지만, Mac이나 Workstation급의
          다른 기종에는 텍스트 모드는 존재하지 않는다.

것을 의미한다. 하지만, GDI가 동작하는 윈도우는 언제나 그래픽 모드이다.
GDI에서 그래픽 작업을 하기 위해서는 현재 출력 장치의 문맥(device
context)을 얻어야 한다. 문맥에는 현재 설정된 선의 모양, 면의 모양,
CP(current point)의 좌표, 맵핑 모드등의 정보 등이 포함된다. 이것을 윈도우
프로그래머는 'DC를 얻는다'고 표현한다.

  윈도우 환경에서 그리기 작업을 위해서는 먼저 DC를 얻어야 한다. DC를
얻는 것이 그리기 작업에만 국한된 것은 아니다. 메모리에 입/출력을 원하면
메모리 DC를 얻어야 한다.

  이러한 DC 구조체는 운영체제에 의해 순서가 매겨진 일련의 블락으로 유지
된다. 운영체제는 이러한 블락에 차례대로 번호를 붙여서 블락을 구분하는데
블락에 붙여진 번호를 핸들(handle)이라 한다. 핸들의 의미는 이 번호만 알면
그 블락이 제어하는 장치를 조정(handle)할 수 있다는 의미이다.

  프로세스를 제어하려면 프로세스 핸들 - 비록 윈도우에서는 이것을 인스턴
스 핸들(instance handle)이라고 하지만 - 을 얻어야 한다. 파일을 제어하려
면 파일 핸들(file handle)을 얻어야 한다. 그리기를 제어하려면 디바이스 컨
텍스트 핸들(device context handle)을 얻어야 한다. 윈도우에서 메모리를
제어하려면 포인터를 얻어야 하는 것이 아니라, 메모리 핸들(memory
handle)을 얻어야 하는데, 이것은 윈도우가 메모리의 일부를 옮기거나, 스왑
(swap)하는 것이 가능하기 때문이다■.

             여기에 관한 자세한 사항은 Charles Petzold의 "Programming Windows 95"
          를 참조하라.

  마침내 화면에 사각형을 그릴 수 있게 되었다. 이제 사각형의 일부를 다른
곳으로 복사하는 작업을 생각해 보자.

             이미 그려진 사각형에서 B영역을 E영역으로 복사하고자 한다. E영역은 C
          와 A의 교집합(intersection)영역이다.

  위의 그림은 다음과 같은 단계로 얻어진 것이다.

    ① 사각형을 그린다.

    ② 원 A를 새로운 그리기 영역(region)으로 설정한다. 이후의 그리기

    함수는 이 영역 안에만 그려진다.

    ③ B를 C로 비트 블락 복사(bit block transfer)한다. D 부분은 짤리

    고(clipping) E 부분만 복사되어 나타난다.

  먼저 영역(region)에 관한 것이다. 영역은 그리기가 허락되는 화면의 임의
의 부분이다. 영역은 여러 개가 만들어져 영역간의 연산(operation)을 허락한
다. 영역과 영역의 교집합(intersection)이나 합집합(union), 차집합(difference)
등이 새로운 영역으로 설정될 수 있다. 영역이 설정되면, 그리기 함수는 영역
의 안에만, 혹은 영역의 밖에만 그려진다. 영역을 만드는 함수 중 타원
(ellipsis) 영역을 만드는 함수를 CreateEllipticRegion()이라고 하면, 함수의 호
출은

CreateEllipticRegion(left,top,right,bottom);

처럼 될 것이다.

  마지막으로 화면에 그려진 그림의 일부를 다른 곳으로 블락 전송(block
transfer)하는 함수이다. 위의 예에서 (x,y)-(right,bottom) 부분은 (x',y')으로
비트 복사 된다. 물론 현재의 그리기 모드를 사용한다.

  이런 함수를 비트 블락 전송(bit block transfer) 함수라고 하며, 비트블
릿(BitBlt)■ 함수라고 짧게 발음한다.

             BIT BLock Transfer의 약자이다.

그러므로 그래픽 라이브러기가 지원해야 하는 기능에는,

    ■ 영역을 설정하는 함수

    ■ 비트맵 및 BitBlt함수

가 있다. 이 밖에도 부드러운 애니메이션을 위한

    ■ 더블 버퍼링(double buffering) 지원 함수

가 있을 수 있으며, 고급 그래픽 라이브러리에는

    ■ 3차원 지원 함수

가 포함 될 것이다. 또한 지금까지의 범주에 들어가지만, 특별한 구분이 필요
한 함수로 비트맵을 그리는 함수와 팔레트 조작 함수가 있을 수 있다.

  BGI vs. GDI

  BGI의 기능은 GDI가 모두 지원한다. GDI가 훨씬 강력하지만, 복잡하고 어
렵다. 또한 일부 기능들은 모두가 지원함에도 불구하고 GDI의 것이 훨씬 강
력하여 자세한 부분까지 조정하는 것이 가능 - 예를 들면 ROP모드나 맵핑
모드 등 - 하다.

  영역(region)에 관한 함수는 BGI에서 제공하지 않는다. 하지만 GDI에서는
더블 버퍼링(double buffering)에 관한 직접적인 지원은 없다. 하지만, 약간의
기교 - 메모리 디바이스 컨텍스트(memory device context)를 얻는 방법 - 를
사용하면, BGI보다 강력하게 만들 수 있다.

  또한 BGI의 모든 함수들은 언급하였지만, GDI의 모든 함수들을 언급하지는
않았다. 그러기에는 GDI는 너무 방대하다.

  BGI에 관한 구현 사항은 자신이 사용하는 컴파일러의 도움말을 참조하라.
이 책에서는 BGI의 자세한 사항들을 설명하지는 않는다.

분류

BGI

GDI

■ 그리기
모드를 열
거나 닫는
함수 외
기타

closegraph()

detectgraph()

graphdefaults()

grapherrormsg()

_graphfreemem()

_graphgetmem()

graphresult()

initgraph()

installuserdriver()

installuserfont()

restorecrtmode()

setgraphmode()

registerbgidriver()

registerfarbgidriver()

CancelDC()

ChangeDisplaySettings()

CreateCompatibleDC()

CreateDC()

CreateIC()

DeleteDC()

DeleteObject()

DeviceCapabilities()

DrawEscape()

EnumDisplaySettings()

EnumObjects()

EnumObjectsProc()

GetCurrentObject()

GetDC()

GetDCEx()

GetDCOrgEx()

GetDeviceCaps()

GetGraphicsMode()

GetObject()

GetObjectType()

GetStockObject()

ReleaseDC()

ResetDC()

RestoreDC()

SaveDC()

SelectObject()

■ 좌표계
및 맵핑 모
드를 설정
하는 함수

cleardevice()

clearviewport()

getviewsettings()

setviewport(),
ClientToScreen()

CombineTransform()

DPtoLP()

GetCurrentPositionEx()

GetGraphicsMode()

GetMapMode()

GetViewportExtEx()

GetViewportOrgEx()

GetWindowExtEx()

GetWindowOrgEx()

GetWorldTransform()

LPtoDP()

MapWindowPoints()

ModifyWorldTransform()

OffsetViewportOrgEx()

OffsetWindowOrgEx()

ScaleViewportExtEx()

ScaleWindowExtEx()

ScreenToClient()

SetGraphicsMode()

SetMapMode()

SetViewportExtEx()

SetViewportOrgEx()

SetWindowExtEx()

SetWindowOrgEx()

SetWorldTransform()

■ 그리기
함수

arc()

bar()

bar3d()

circle()

drawpoly()

ellipse()

fillellipse()

fillpoly()

floodfill()

line()

linerel()

lineto()

outtext()

outtextxy()

pieslice()

putpixel()

rectangle()

sector(),
아래에 별도로 리스트 한다.

■ GDI의 그리기 함수

1) Line and Curve Functions: 아래의 함수들은 직선과 곡선을 그리는데 사용
된다.

AngleArc()

Arc()

ArcTo()

GetArcDirection()

LineDDA()

LineDDAProc()

LineTo()

MoveToEx()

PolyBezier()

PolyBezierTo()

PolyDraw()

Polyline()

PolylineTo()

PolyPolyline()

SetArcDirection()

2)  Filled Shape Functions: 아래의 함수들은 면이 채워진 도형을 그리는데
사용된다.

Chord()

Ellipse()

FillRect()

FrameRect()

InvertRect()

Pie()

Polygon()

PolyPolygon()

Rectangle()

RoundRect()

3)  Font and Text Functions: 아래의 함수들은 폰트를 설정하고 글을 출력
하는데 사용하는 함수이다.

AddFontResource()

CreateFont()

CreateFontIndirect()

CreateScalableFontResource()

DrawText()

DrawTextEx()

EnumFontFamilies()

EnumFontFamProc()

EnumFonts()

EnumFontsProc()

ExtTextOut()

GetAspectRatioFilterEx()

GetCharABCWidths()

GetCharABCWidthsFloat()

GetCharacterPlacement()

GetCharWidth()

GetCharWidth32()

GetCharWidthFloat()

GetFontData()

GetGlyphOutline()

GetKerningPairs()

GetOutlineTextMetrics()

GetRasterizerCaps()

GetTabbedTextExtent()

GetTextAlign()

GetTextCharacterExtra()

GetTextColor()

GetTextExtentExPoint()

GetTextExtentPoint()

GetTextExtentPoint32()

GetTextFace()

GetTextMetrics()

PolyTextOut()

RemoveFontResource()

SetMapperFlags()

SetTextAlign()

SetTextCharacterExtra()

SetTextColor()

SetTextJustification()

TabbedTextOut()

TextOut()

■ 팔레트
함수

getdefaultpalette()

getpalette()

getpalettesize()

setallpalette()

setpalette()

setrgbpalette(),
AnimatePalette()

CreateHalftonePalette()

CreatePalette()

GetColorAdjustment()

GetNearestColor()

GetNearestPaletteIndex()

GetPaletteEntries()

GetSystemPaletteEntries()

GetSystemPaletteUse()

RealizePalette()

ResizePalette()

SelectPalette()

SetColorAdjustment()

SetPaletteEntries()

SetSystemPaletteUse()

UnrealizeObject()

UpdateColors()

■ 그리기
속성을 설
정하거나
얻는 함수

getarccoords()

getaspectratio()

getbkcolor()

getcolor()

getdrivername()

getfillpattern()

getfillsettings()

getgraphmode()

getlinesettings()

getmaxcolor()

getmaxmode()

getmaxx()

getmaxy()

getmodename()

getmoderange()

getpixel()

gettextsettings()

getx()

gety()

moverel()

moveto()

setaspectratio()

setbkcolor()

setcolor()

setfillpattern()

setfillstyle()

Get과 Set으로 시작하는 모든
함수들

■ 그리기
속성을 설
정하거나
얻는 함수

setgraphbufsize()

setlinestyle()

settextjustify()

settextstyle()

setusercharsize()

textheight()

textwidth()

Get과 Set으로 시작하는 모든
함수들

■ 그리기
모드(raster
operation)
를 설정하
는 함수

setwritemode()

SetROP2()

GetROP2()

■ 영역을
설정하는
함수

없음

CombineRgn()

CreateEllipticRgn()

CreateEllipticRgnIndirect()

CreatePolygonRgn()

CreatePolyPolygonRgn()

CreateRectRgn()

CreateRectRgnIndirect()

CreateRoundRectRgn()

EqualRgn()

ExtCreateRegion()

FillRgn()

FrameRgn()

GetPolyFillMode()

GetRegionData()

GetRgnBox()

InvertRgn()

OffsetRgn()

PaintRgn()

PtInRegion()

RectInRegion()

SetPolyFillMode()

■ 비트맵
및 BitBlt함
수

getimage()

imagesize()

putimage(),
BitBlt()

CreateBitmap()

CreateBitmapIndirect()

CreateCompatibleBitmap()

CreateDIBitmap()

CreateDIBSection()

CreateDiscardableBitmap()

ExtFloodFill()

FloodFill()

GetBitmapBits()

GetBitmapDimensionEx()

GetDIBColorTable()

GetDIBits()

GetPixel()

GetStretchBltMode()

LoadBitmap()

MaskBlt()

PatBlt()

PlgBlt()

SetBitmapBits()

SetBitmapDimensionEx()

SetDIBColorTable()

SetDIBits()

SetDIBitsToDevice()

SetPixel()

SetPixelV()

SetStretchBltMode()

StretchBlt()

StretchDIBits()

■ 더블 버
퍼링(double
buffering)
지원 함수

setactivepage()

setvisualpage()

CreateCompatibleDC() 및 비트맵
함수

■ 3차원
지원 함수

없음

없음

  실습문제

1.


43. 알고리즘 설계 기법

아래의 글은 한국과학기술원 좌경룡 교수님이 한국정보과학회 논문지 -
1994년 2월 제12권 제1호, pp.21-36 - 에 게재한 글입니다.

목    차

1. 서론				5. 퇴각 검색법

2. 분할 해결법			6. 분기와 한정법

3. 동적 계획법			7. 무작위 알고리즘

4. Greedy 방법

  1. 서론

  알고리즘이란 한 마디로 말해 "특정한 일을 수행하기 위한 절차"를 말한다.
만일 컴퓨터로 하여금 특정한 일을 수행하게 하고 싶다면 제일 먼저 해야 할
일이 기계가 정확히 무엇을 어떠한 순서로 해야 하는지를 설계하고 이를 기계
에게 알려주는 일 - 즉, 알고리즘을 설계하고 이를 알려주는 일이 핵심이 된
다. 컴퓨터가 수행해야 할 알고리즘만으로 알고리즘을 제한 하는 경우 "언젠
가는 종료되는 명확한 유한개의 작업순서"만을 알고리즘이라 한다. 그렇지 않
은 경우는 실제 실용가치가 없기 때문이다. 여기에서 명확하다는 것은 각 작
업 단계에서 다음에 수행하여야 할 작업이 무엇인지 그 상황에서 유일하게 결
정되어야 한다는 것을 의미한다. 앞으로 컴퓨터가 수행하는 알고리즘으로만
알고리즘을 제한하기로 한다.

  어떤 특정한 문제를 해결하기 위한 알고리즘을 설계 했다고 하자. 그러면
다음 단계는 설계한 알고리즘대로 기계를 작동시키기 위해 기계가 이해할 수
있는 언어를 사용하여 그 알고리즘을 구체적으로 프로그램의 형태로 기술하는
일이다. 이때 기계의 환경, 프로그래밍 언어, 프로그래머의 스타일 등 여러 요
인에 의해 동일한 알고리즘이 수많은 다른 프로그램으로 구현될 수 있다.

  컴퓨터 프로그램의 효율을 평가할 때 프로그램의 처리 속도가 중요한 척도
중의 하나인데, 그 프로그램의 알고리즘 및 위에서 언급한 여러 요인들에 의
해 처리속도가 좌우된다. 최근들어 컴퓨터의 사용이 급격히 늘어나면서 컴퓨
터를 이용하는 분야가 크게 확대되고 있다. 그 결과 해결해야 하는 문제가 점
점 복잡해 지고 특히 다루어야 하는 자료의 분량이 폭발적으로 많아지게 되면
서 효율적인 알고리즘을 사용하는지 여부가 전체 프로그램의 동작효율에 중요
한 영향을 미치게 되었다. 본 고에서는 이러한 실제 문제를 해결할 때 체계적
으로 알고리즘을 설계하는데 도움이 될 수 있도록 하기 위해 일반적으로 사용
되는 정형화된 알고리즘 설계 기법들을 소개한다. 특히 예를 들어 설명하는데
중점을 둔다.

2. 분할 해결법(divide-and-conquer)

  어떤 문제 P를 해결하는 알고리즘이 다음과 같은 골격을 가지고 있으면 그
알고리즘을 분한 해결법 알고리즘이라 할 수 있다. 분할 해결법 알고리즘은
주어진 입렵의 숫자가 P를 쉽게 해결할 수 있을 정도로 충분히 작으면 있는
그대로 간단히 해결한다. 만일 그렇지 않으면 주어진 입력을 보다 작은 갯수
를 가지는 여러 부분으로 나누되 나누어진 각 부분은 P에 대한 독립된 하나
의 입력이 되도록 한다. 다음에 나누어진 각 입력에 대해 순환(recursive) 호
출을 한다. 순환 수행이 끝나면 그 결과를 합하여 원래 문제에 대한 해를 구
한다.

  합병정렬(merge sort)은 잘 알려진 정렬 알고리즘으로서 전형적인 분할 해
결법 알고리즘이다. 다음의 합병정렬 알고리즘에서는 원소의 갯수가 n개인 배
열 A의 원소를 정렬하는데 전체를 정렬하기 위해서는 Merge_Sort(1,n)을 수
행하면된다. A(i..j)는 배열 A의 i번째 원소로부터 j번째 우너소까지의 연속된
부분 배열을 의미한다. A는 전역 변수(global variable)이다.

Algorithm Merge_Sort(first, last)

입력: A(first..last)

출력: A(first..last)가 정렬되어 재배치된 것

1. if first<last then

2.   mid:=ㆃ(first+last)/2ㆄ

3.   Merge_Sort(first, mid)

4.   Merge_Sort(mid+1, last)

5.   A(first..mid)와 A(mid+1..last)를 합병하여 A(first..last)의 정렬된 결과를
구하고 이를 A(first..last)에 저장한다.

6. endif

end Merge_Sort

  Merge_Sort에서 분할 해결법의 골격을 살펴보자. 먼저 알고리즘에는 명시
되어 있지 않지만 입력자료의 갯수(A(first..last)의 원소 갯수)가 1이하이면 이
미 정렬되어 있는 것이므로 더 이상 작업을 하지 않는다. 즉, 정렬을 간단히
해결한 것이라 볼 수 있다. 만일 갯수가 2 이상이라면 입력자료를
A(first..mid)와 A(mid+1..last)로 나누어 이에 대해 순환 수행한 수에(단계
2,3,4) 정렬된 두 부분을 하나의 정렬된 부분으로 합병한다(단계 5). 이 예에서
는 입력을 두 부분으로 나누었으나 경우에 따라서는 세개 이상의 부분으로 나
눌 수 도 있다.

  분할 해결법 알고리즘을 설계할 때 가장 중요하게 고려하여야 할 부분은 나
누어진 각 부분의 입력의 갯수를 효율적으로 분배해서 전체 알고리즘이 효율
적으로 동작하도록 해야 한다는 것이다. Merge_Sort의 시간 복잡도를 분석하
면서 이를 검토해 보자.

  Merge_Sort에 의해 소요되는 최악의 수행시간을 T(n)이라 하자. 이때 n은
입력 배열의 원소의 갯수이다. 먼저 n=2k일 경우(k는 양의 정수)에 대해 분석
해 보자. 단계 3은 T(n/2) 시간이 소요되고 단계 4에서도 T(n/2) 시간이 소요
된다. 단계 5는 O(n) 시간이 소요되도록 구현할 수 있으므로 다음이 성립한
다.

T(n) = LEFT { LPILE {O(1) & if n=1 # 2T(n/2)+O(n) & if n>1}

  위의 경우 T(n)=O(nlogn)임을 쉽게 알 수 있다. 만일 2k<n≤2k+1일 경우(k
는 양의 정수) T(n)≤T(2k+1)이므로 역시 T(n)=O(nlogn)이다. 따라서
Merge_Sort의 시간 복잡도는 O(nlogn)이다.

  이제 입력을 1개와 n-1개로 나누면 - 즉, 단계 2를 mid:=first로 수정하면 -
Merge_Sort의 시간 복잡도가 어떻게 변하는지 살펴보자. 이 경우 다음이 성
립한다.

T(n) = LEFT { LPILE {O(1) & if n=1 # T(1)+T(n-1)+O(n) & if n>1}

  이 경우 T(n)=O(n2)임을 쉽게 알 수 있다. 이 예에서 볼 수 있듯이 분한 해
결법 알고리즘에서 주어진 입력을 여러 부분으로 나눌 때 나누어진 부분의 입
력의 갯수를 효율적으로 분배하여 전체 알고리즘의 효율이 높아지도록 하는
것이 중요하다. 주어진 입력을 어떻게 나누는가에 따라 일반적으로 다음이 성
립한다면

T(n) = LEFT { LPILE {O(1) & if n=1 # aT(n/b)+O(n) & if n<1}

T(n)은 다음과 같이 된다.

T(n) = LEFT { LPILE {O(n) & if a>b #

                       O(nlogn) & if a=b #

                       O(n sup {log sub b a} ) & if a<b}

  또 다른 분할 해결법 알고리즘으로서 다음 문제에 대한 알고리즘을 예로 든
다.

문제 (최 근접 쌍) 평면상의 n개의 점에 대한 좌표가 주어졌을 때 그 중에서
가장 가까운 쌍(pair)을 찾아라.

  이 문제에 대한 가장 간단한 알고리즘은 n(n-1)/2개의 모든 쌍에 대해 거리
를 계산해 보는 것이다. 이렇게 하면 시간 복잡도가 O(n2)이다. 다음의 알고리
즘은 O(nlogn) 시간 복잡도를 가지는 분할 해결법 알고리즘이다.

Algorithm Closest_Park(S, p, q)

입력: 평면상의 n개의 점의 집합 S

출력: S의 점들 중 거리가 가장 가까운 쌍 (p, q)

1. 만일 |S|≤2이면 간단히 해결된다. 그렇지 않으면 다음을 수행한다.

2. 가상의 수직선 l을 사용하여 S를 두 부분 S1,S2로 나누되 S1을 l의 왼쪽에,
S2를 l의 오른쪽에 있도록 하고 |S1|=㆓n/2㆔, |S2|=ㆃn/2ㆄ이 되도록 한다(그림 1).

3. Closest_Pair(S1, p1, q1)

4. Closest_Pair(S2, p2, q2)

5. p1과 q1사이의 거리와 p2와 q2사이의 거리 중에서 작은 값을 δ라 하자.

6. S1의 점들 중에서 l로 부터 거리가 δ이하인 것을 모아 P1을 구성하고 S2의
점들 중에서 l로 부터 거리가 δ이하인 것을 모아 P2를 구성한다. P1과 P2를 y
좌표값에 따라 정렬한다. 이를 각각 P*1, P*2라 하자.

7. P*1의 한 점 s의 y좌표값을 sy라 할 때 y좌표값이 sy-δ와 sy+δ사이에 있
고 x좌표값이 lx와 lx+δ사이에 있을 영역을 Ws안에 있는 모든 점 t와 s오의
거리를 계산한다. P*1를 따라가면서 P*1의 각 점 s에 대해 위의 과정을 반복한
다. 거리가 계산된 (s, t)쌍 중에서 가장 가까운 것을 선택하여 그 두점을 각
각 p3, q3으로 한다.

8. (p1, q1), (p2, q2), (p3, q3)중에서 가장 가까운 것을 (p, q)로 한다.

end Closest_Pair

  위 알고리즘의 단계 2에서는 입력으로 주어진 점의 집합 S를 같은 크기의
두 집합 S1과 S2로 나눈다. 전체 알고리즘의 전처리 과정으로서 S의 점들을 x
좌표값에 따라 처음에 한번만 정렬해 놓고 이를 분할하면 S1과 S2를 O(n)시
간에 구할 수 있다. 더욱이 S1과 S2의 점들이 x좌표값에 따라 정렬되어 있으
므로 이들은 각각 단계 3과 4의 순환수행 단계에서 입력을 분할하는데 같은
방법으로 이용될 수 있다.

  이제 S1에서 가장 가까운 쌍 (p1, q1), S2에서 가장 가까운 쌍 (p2, q2), S1
의 한 점과 S2의 한점으로 이루어진 쌍 중에서 가장 가까운 쌍 (p3, q3)중에
서 두 점 pi와 qi사이의 거리가 가장 가까운 쌍이 우리가 원하는 쌍이다(1≤i
≤3). 처음의 두 경우에 대해서는 단계 3과 4에서 순환수행으로 구하고 마지
막 경우에 대해서는 단계 5,6,7에서 구한다. 이때 (p3, q3)을 구할 때 l로부터
거리가 δ이내에 있는 점만 고려해도 된다. 단계 6에서는 이러한 점들을 찾는
다. 전처리 과정으로서 S의 점을 y좌표값에 따라 처음에 한번만 정렬해 놓으
면 이로부터 P*1과 P*2를 O(n)시간에 구할 수 있다. 단계 3과 4에서 순환수행
될 때고 각각 P*1과 P*2를 이용하면 같은 방법으로 구할 수 있다.

  단계 7에서 s를 고려할 때 Ws에 있지 않은 P2의 점은 고려할 필요가 없다.
그러한 점들은 s로부터의 거리가 δ보다 크기 때문이다. 또한 Ws안에 있는
점들도 서로 거리가 적어도 δ만큼은 되어야하므로 Ws에는 그림 1에서와 같
이 6개 이하의 점들만 있을 수 있다. 따라서 단계 7은 O(n) 시간에 수행된다.

  결국 Closest_Pair의 최악의 수행시간을 T(n)이라 한다면 다음이 성립한다.

T(n) = LEFT { LPILE {O(1) & if n≤2 # 2T(n/2)+O(n) & if n>2}

따라서 Closest_Pair의 계산 복잡도는 O(nlogn)이다.

  점화식의 시간 복잡도를 분석하는데 아래의 정리가 매우 유용하게 이용될
것이다.

Let f be an increasing function that satisfies the recurrence relation

f(n) = af(n/b) + cn sup d

whenever n=bk, where k is a positive integer, a≥1, b is an integer greater
than 1, and c and d are positive real numbers. Then

f(n) = CASES{ O(n sup d ) & if a < b sup d #

O(n sup d log n ) & if a = b sup d #

O(n sup { log sub b a } ) & if a > b sup d }

3. 동적 계획법(dynamic programming)

  동적 계획법은 개념적으로 볼 때 주어진 문제를 여러 부분문제
(subproblem)로 분할하여 순환 수행한다는 점에서 분할 해결법과 같으나 분
할 해결법의 경우 한 번 순환수행된 부분문제는 다시 수행되지 안흔遁 경우에
적당하고 동적 계획법은 다시 수행될 수 있을 때 사용되는 방법이다.

  반복해서 여러분 호출되는 부분문제가 있을 경우 해당되는 부분문제가 첫번
재 수행될 때 그 결과를 기록해 두었다가 이후에 다시 수행이 요구될 때 또
수행하는 것이 아니라 이전에 기록된 결과를 이용하는 것이 동적 계획법에서
핵심이 되는 부분이다. 참고로 "dynamic programming"에서 "programming"이
라는 말은 컴퓨터 프로그램과는 무관하고 표를 이용하는 방법이라는 뜻이다.

  동적 계획법을 사용한 알고리즘의 일반적인 구조를 설명하기 위해 다음의
문제에 대한 알고리즘을 예로 든다.

  A=(aij) p×q행렬이라 하고 B=(bij)를 q×r행렬이라 하자. 두 행렬을 곱하는
알고리즘은 여러가지가 있을 수 있으나 정의에 따른 알고리즘으로 제한해 보
자. 즉, C=(cij)=AB라 할 때 cij=∑1≤k≤qaikbkj 에 따라 C를 구하면(1≤i≤p, 1≤
j≤r)행렬의 원소끼리 곱셈이 일어나는 횟수는 pqr번이다. 만일 여러개의 행렬
을 곱한다면 전체 알고리즘의 수행 속도가 원소끼리의 곱셈 횟수에 크게 좌우
되므로 이를 계산해 보자. 예를 들어 A1, A2, A3을 각각 2×3, 3×4, 4×5행렬
이라고 하자. A1, A2, A3을 구하기 위해서 두 가지 방법이 있다. 즉, (A1A2)A3
의 순서와 A1(A2A3)의 순서이다. 그런데 앞의 경우 전체 원소 곱셈의 횟수는
2×3×4+2×4×5=64번이고 뒤의 경우에는 3×4×5+2×3×5=90번이다. 따라서
앞의 방법이 더욱 효율적이다. 다음 문제는 일반적인 경우에 가장 효율적인
방법을 찾는 것이다.

문제 (행렬의 곱셈 순서) n개의 행렬 A1,A2,...,An이 주어져 있다. Ai가 di-1×di
행렬이라 할 때(1≤i≤n) 전체 행렬곱 A1A2...An을 구하려 한다. 원소 곱셈의
전체 횟수를 최소로 하기 위한 행렬 곱셈의 순서를 찾아라.

  최적의 행렬곱셈 순서를 계산하기 전에 먼저 최소의 원소곱셈의 횟수를 계
산해 보자. Ai...Aj를 계산하기 위해 사용되는 최소의 원소 곱셈의 횟수를 Mij
라 하자(1≤i≤j≤n). Ai...Aj를 계산하는 순서에게 제일 마지막에 계산되는 행
렬곱이 (Ai...Ak)(Ak+1...Aj)라 하면(i≤k≤j) 앞부분은 di-1×dk 행렬이고 뒷 부분
은 dk×dj 행렬이므로 다음의 순환식이 성립한다.

M sub ij = CASES { 0 & if i=j #

min sub {i<=k<j} left { M sub ik + M sub k+1,j + d sub i-1 d sub k d
sub j right } & if 1 <= i <= j <= n }

  위의 식은 이미 그대로 순환 알고리즘으로 구현될 수 있다. 그런데 이렇게
하면 시간 복잡도가 Ω(2n)이 되어 매우 비효율적이다. 그 이유는 동일한 순환
수행이 여러번 반복하여 호출되기 때문이다. 예를 들어 n=10일 경우 M3,7은
M1,8에서 호출되고 M2,10에서도 호출되는 등 모두 37번 호출되는데, 호출될때
마다 그 자신이 순환수행을 계속 반복한다. 만일 M3,7이 처음 호출될 때에만
그 자신이 순환수행을 한 후 그 결과를 기록해 두고 이후에 호출될 때는 단순
히 기록된 값을 참조하기만 하면 효율적인 것이다.

  이와 같이 여러 번 반복되어 호출되는 부분문제를 처음에 한번만 실제 계산
하여 이를 기록해 두고 이후에 호출될 때는 이를 단순히 참조하여 알고리즘의
효율을 높이는 것이 동적 계획법의 핵심이다.

  이 문제의 경우 Mij값을 2차원 배열에 기록하는 것이 적당하다. 기록되는
배열 이름도 역시 M이라 하고 Mij값을 M(i,j)에 기록한다고 하자. 앞으로 편
의상 Mij와 M(i,j)를 같은 의미로 사용하기로 하겠다. 그런데 Mij를 계산하기
위해서는 i≤k<j인 모든 k에 대해 Mik와 Mk+1,j가 계산되어 있어야 하는데 이
는 그림 2에서 빗금 친 부문에 해당된다. 따라서 이를 만족하는 순서를 찾아
이를 따라 단순히 배열 값만을 계산하면 순환수행 없이 M1n값을 계산할 수
있다.

Algorithm Min_Mult(D)

입력: D=(d0,d1,...,dn)

출력: M1n 값

1. for i:=1 to n do Mii:=0;

2. for d:=1 to n-1 do

3.     for i:=1 to n-d do

4.         j:=i+d

5.

M sub ij = min sub {i<=k<j} left { M sub ik + M sub k+1,j + d sub i-1
d sub k d sub j right }

6.     endfor

7. endfor

8. return M1n

end Min_Mult

  이 알고리즘의 계산 복잡도는 O(n3)이므로 단순한 순환 알고리즘 보다 매유
효율적이다. 만일 M1n값뿐만 아니라 최적의 행렬 곱셈순서까지 계산하려면
단계 5에서 M(i,j)를 구하는데 사용된 k값이 무엇인지를 M(i,j)에 추가로 기록
하면 된다. 나중에 이 정보를 역으로 추적하면 최적의 행렬 곱셈순서를 구할
수 있다. 이렇게 해도 시간 복잡도는 변하지 않는다.

  일반적으로 동적 계획법 알고리즘을 설계하는 순서를 정리하면 다음과 같
다. 먼저 주어진 문제의 최적해를 구하기 위한 순환구조를 설계한 후에 이로
부터 각 부분 문제가 해결되어야 할 순서를 결정한다. 각 부분 문제의 결과를
저장하기 위한 기억장소를 할당한 후에 앞에서 결정한 수선에 따라 각 부분
문제를 해결하도록 한다. 이 때 각 부분 문제가 해결되어야 할 순서를 따르기
때문에 각 부분 문제를 해결하기 위해서 순환 수행 없이 단순히 이전에 계산
된 값을 참조하기만 하면 된다.

  또 다른 예로서 다음 문제를 효율적으로 해결할 수 있는 동적 계획법 알고
리즘을 예로 든다.

문제 (Bitonic TSP) 평면상의 n개의 점의 좌표가 주어져 있다. 이 때 모든 점
의 x좌표값은 서로 다르다. 모든 점을 통과하여 제자리로 다시 돌아 오는 가
장 짧은 경로를 구하라. 단, 임의의 수직선과 경로가 교차하는 점은 항상 두개
이하가 되도록 해야 한다.

  경로의 모양에 제한이 없는 일반적인 문제는 NP-complete 문제이나 이 문
제는 동적 계획법을 사용하여 O(n2) 시간에 해결할 수 있다. 앞으로 최적 경
로의 길이를 구하는 알고리즘만을 설명한다. 이 알고리즘의 자료구조를 조금
만 수정하면 최적 경로까지 구하는 알고리즘으로 쉽게 수정할 수 있다.

  주어진 점들이 x좌표값에 의해 정렬된 것을 p1,p2,...,pn이라 하고, 점
p1,p2,...,pi만을 사용하는 최적 경로를 Ti라하며, Ti의 길이를 |Ti|로 표시하기로
하자. 그리고 두 점 p와 q를 연결하는 선분을 pq로 표시하고 그 길이를 |pq|
로 표시하기로 하자. 이제 |Ti|의 값을 구하는 방법을 살펴보자. 먼저 i가 3이
하일 경우에는 |Ti|를 쉽게 계산할 수 있다. 그렇지 않은 경우 pi-1pi는 항상 Ti
에 포함되어야 한다는 것을 쉽게 알 수 있다. 이제 Ti에서 pi에 연결된 또 다
른 점이 될 가능성이 있는 것은 p1,p2,...,pi-2중의 하나이다. Ti에서 pi-1외에 pi에
연결된 또 다른 점이 pj로 고정된 경우의(1≤j≤i-2) 최소길이 경로를 Ti(j)라고
하고 그 길이를 |Ti(j)|로 표시하기로 하자. 그러면 다음이 성립한다.

|Ti|=min1≤j≤i-2{|Ti(j)}|

  Ti(j)에 있어서 pj+1부터 pi-1까지는 그림 3에서와 같이 항상 x좌표 값이 증가
하는 순서대로 연결되어야 한다. 만일 그렇지 않다면 경로의 방향에 대한 규
칙을 만족할 수 없다. 또한 pj에서 p1을 거쳐 pj+1에 이르는 나머지 경로는 Tj+1
에서 pjpj+1을 제외한 부분과 같다. 만일 그렇지 않다면 Tj+1이 p1,...,pj+1을 거치
는 최소 길이의 경로라는데 모순된다. pa부터 pb까지 x좌표 값이 증가하는 순
서대로 연결한 경로의 길이를 ma,b라 하자(1≤a<b≤n). 이제 다음이 성립함을
알 수 있다.

|Ti(j)|=|pjpi|+mj+1,i+|Tj+1|-|pjpj+1|

따라서 다음이 성립한다.

|Ti|=min1≤j≤i-2{|Ti(j)|}

   =min1≤j≤i-2{|pjpi|+mj+1,i+|Tj+1|-|pjpj+1|}

  |Ti|를 구하기 위해서는 |T1|,...,|Ti-1|이 미리 계산되어 있으면 되므로 다음의
동적 계획법 알고리즘을 설계할 수 있다. 이 때 |Ti|값을 저장하기 위해 일차
원 배열을 사용하면 된다. 사용되는 배열을 L이라 할 때 L(i)에는 |Ti|값을 기
록한다. 앞으로 편의상 |Ti|와 L(i)를 같은 의미로 사용하기로 하겠다.

Algorithm Bitonic_TSP(S)

입력: S=(p1,p2,...,pn), n≥3

출력: |Tn|

1. |T1|, |T2|, |T3|값을 계산한다.

2. for i:=4 to n do

3.     |Ti|:=min1≤j≤i-2{|pjpi|+mj+1,i+|Tj+1|-|pjpj+1|}

4. endfor

5. return |Tn|

end Bitonic_TSP

  전처리 과정으로서 모든 ma,b값을 O(n2)시간에 구할 수 있고 이를 2차원 배
열에 저장해 두면 위 알고리즘이 수행되는 도중에는 mj+1,i값을 O(1)시간에 알
수 있다. 이를 이용하면 이 알고리즘의 시간 복잡도는 O(n2)이다.

4. Greedy 방법

  Greedy 알고리즘은 주어진 조건을 만족하는 최적해를 찾는데 주로 사용되
는 기법이다. 이는 단계별로 진행되는데 각 단계에서는 현재 상태에서 가장
최적이라고 판단되는 결정을 내린다. 이 때 전체적으로 최적인지 여부는 검사
하지 않고 현재 상태의 입장에서만 보았을 때 가장 최선이라고 판단되는 결정
을 내린다. 따라서 Greedy 알고리즘에서는 어떻게 단계의 순서를 결정해야만
얻어진 해가 전체적으로 최적해가 될 수 있는가가 핵심이다.

  Greedy 알고리즘의 일반적인 구조를 설명하기 위해 다음 문제에 대한 알고
리즘을 예로 들어 설명한다.

문제 (회의실 배정) 한 개의 회의실이 있는데 이에 대한 사용신청에 따라 회
의실 사용표를 만들려고 한다. 사용신청은 각 회의의 시작 시간과 끝나는 시
간의 집합 S={(si, fi)|1≤i≤n}으로 나타내어지는데 si는 회의 i의 시작 시간을,
fi는 끝나는 시간을 의미한다. 각 회의가 서로 시간이 겹치지 않게 하면서 회
의실을 사용할 수 있는 최대 숫자의 회의를 찾아라. 단, 회의는 한번 시작하면
중간에 중단될 수 없으며 한 회의가 끝나는 것과 동시에 다음 회의가 시작될
수 있다.

  다음의 알고리즘은 이 문제를 해결하기 위한 전형적인 Greedy 알고리즘이
다.

Algorithm Greedy_Schedule(S, Solution)

입력: S={(si, fi)|1≤i≤n}

출력: Solution⊆S

1. 각 회의의 종료시간을 증가하는 순서로 정렬한다. 일반성을 잃지 않고 이를
f1,f2,...,fn이라 하자.

2. Solution:={(s1,f1)}; last:=1

3. for i:=2 to n do

4.     if si≥flast then

5.         Solution:=Solution∪{(si,fi)}

6.         last:=i

7.     endif

8. endfor

end Greedy_Schedule

  이제 Greedy 알고리즘의 일반적인 구조를 위의 예를 가지고 살펴보자.
Greedy 알고리즘은 주어진 n개의 입력 중에서 특정한 제약을 만족하는 부분
집합을 고르는 문제에 이용된다. 위의 예에서는 입력은 S이고 제약조건은 회
의들이 서로 겹치지 않아야 한다는 것이다. 입력의 부분집합 중에서 제약 조
건을 만족하는 것을 가능해(feasible solution)라 한다. 가능해 중에서 목적
함수(objective function)를 최적으로 하는 해를 최적해(optimal solution)
라 한다. 위의 예에서는 선택된 회의의 갯수가 목적함수이고 회의의 갯수를
최대로 하는 것이 목적함수를 최적으로 하는 것이다.

  최적해를 찾기 위해 Greedy 알고리즘은 단계별로 현재 남아 있는 입력집합
을 검사해서 그 중에서 최적해에 들어갈 수 있다고 판단되는 것을 골라낸다.
이 때 전체적으로 보았을 때 최적이될지 여부는 무시하고 현재 단계에서 보았
을 때 가장 최적이라고 판단되는 것을 무조건 고른다. 나중에 어떻게 될지는
고려하지 않고 현재 상태에서 가장 최적이라고 생각되는 것을 무조건 고르기
때문에 Greedy 알고리즘이라 한다. 현재 상태에서 보았을 때 최적해에 들어
갈 수 있다고 판단되는 것을 골라내는 규칙을 선택 규칙(selection rule)이라
부르기로 하자. 물론 선택규칙은 문제에 따라 다르다. 선택 규칙에 따라 골라
낸 것을 이제까지 찾은 해에 포함시켰을 때 그것이 가능해가 된다면 그것을
현재 단계에서 새로 결정된 해로 하여 다음 단계로 계속한다.

  다시 위의 알고리즘을 보면 단계 3에서 8사이가 단계별로 진행되는 부분이
고 선택규칙은 현재까지 찾은 해인 Solution에 포함된 회의를 모두 마치고 났
을 때 남아 있는 회의 중에서 가장 빨리 끝낼 수 있는 것을 골라내는 것이다.
전체적으로 고려하지 않고 현재 상태에서 가장 최적이 될 수 있는 것을 골라
내므로 Greedy_Schedule은 전형적인 Greedy 알고리즘이다. 현재 단계에서 골
라낸 것이 가능해가 될 수있는지 판단하는 단계는 단계 4이다.

  위의 예에서 보았듯이 Greedy 알고리즘에서 가장 중요한 것은 전체적으로
쉘거이 되도록 하는 선택 규칙을 찾는 것이다. 그러나 적용된 선택 규칙을 이
용하면 항상 최적해를 찾을 수 있다는 것을 증명하는 것은 대부분의 경우 쉽
지 않은 작업이다.

  다음은 잘 알려진 Knapsack 문제인데 이에 대한 Greedy 알고리즘을 예로
제시하면서 이에 사용된 선택규칙이 올바르다는 것을 증명한다. 특히 이 증명
은 Greedy 알고리즘에서 자주 사용되고 있는 증명방법을 보여주고 있기 때문
에 주목할 만하다.

문제 (Knapsack) 최대 M㎏까지 물건을 집어넣을 수 있는 배낭이 있고 이 배
낭에 집어넣고 싶은 n개의 물건이 있다. 물건 i를 배낭에 넣으면 pi만큼 이득
이 있고(pi>0) i의 무게는 wig㎏인데(wi>0) 이를 잘라 일부를 넣을 수도 있다.
만일 물건 i를 xi만큼 배낭에 넣는다면(0≤xi≤1) pixi만큼 이득이 있다. 전체
무게가 M㎏ 이하가 되면서 최대 이득을 얻을 수 있도록 하려면 각 물건을 얼
마만큼 넣어야 하는가?

  이 문제를 해결 할 수 있는 Greedy 알고리즘에 적용될 수 있는 선택규칙을
고려해 보자. 배낭에 들어갈 수 있는 전체 무게가 제한되어 있으므로 단위무
게 당 이득이 높은 것부터 배낭에 집어넣는 선택규칙을 생각할 수 있다. 이를
적용하면, 다음과 같은 Greedy 알고리즘이 된다.

Algorithm Greedy_Knapsack(P, W, M, X)

입력: P=(p1,...,pn), W=(w1,...,wn), M

출력: X=(x1,...,xn)

1. 각 물건을 pi/wi값이 감소하는 순서로 정렬한다. 일반성을 잃지 않고 pi/wi
≤pi+1/wi+1이라 하자(1≤i<n).

2. X:=(0,...,0)

3. remained:=M

4. for i:=1 to n do

5.     if wi>remained then goto 9

6.     xi:=1

7.     remained:=remained-wi

8. endfor

9. if i≤n then xi:=remained/wi

end Greedy_Knapsack

  위 알고리즘의 시간 복잡도는 O(nlogn)임을 쉽게 알 수 있다. 이제 이 알고
리즘에서 사용된 선택규칙이 항상 최적해를 보장함을 증명한다.

  먼저 Greedy_Knapsack의 결과를 X=(x1,...,xn)이라 하자. 만일 모든 xi값이
1이라면 X는 최적해이다. 그렇지 않은 경우 xj를 xj≠1이면서 가장 작은 j값
을 가지는 것이라 하자. 알고리즘의 동작에 따르면 1≤i≤j일 경우 xi=1이고 0
≤xj≤1이며 j<i≤n일 경우 xi=1이다. 만일 X가 최적해가 아니라면 ∑piyi>∑
pixi 인 최적해 Y=(y1,...,yn)가 존재한다. Y가 최적해이므로 ∑wiyi=M이다. 또
한 ∑piyi>∑pixi이기 때문에 yi≠xi인 i가 존재하는데 그러한 i값중에서 가장
작은 것을 k라 하자. 이제 k와 j값의 관계에 따라 세가지 경우가 있을 수 있
다. 만일 k<j라면 xk=1이므로 yk<xk이다. 만일 k=j라면 ∑wixi=∑wiyi=M이
기 때문에 역시 yk<xk이다. 마지막으로 k>j인 경우는 있을 수 없다. 만일 그
렇다면 ∑wiyi>M이기 때문이다. 따라서 k와 j의 값에 관계없이 항상 yk<xk
이다. 이제 yk값으로 증가시키고 그 대신 (yk+1,...,yn) 값들을 적당히 감소시
켜 새로운 해 Z=(z1,...,zn)을 만들되 1≤i≤k인 i에 대해서는 zi=xi이고 ∑k<i
≤nWi(yi-zi)=wk(zk-yk)이 되도록 할 수 있다. 이로부터 다음이 성립합을 알
수 있다.

 SUM from {1<=i<=n} p sub i z sub i = & SUM from{1<=i<=n} p sub i
y sub i + (z sub k - y sub k )w sub k p sub k / w sub k - SUM from
{k<i<=n} ( y sub i - z sub i ) w sub i p sub i / w sub i #

& >= SUM from {1<=i<=n} p sub i y sub i + [ (z sub k - y sub k ) w
sub k - SUM from {k<i<=n} ( y sub i - z sub i ) w sub i ] p sub k /
w sub k #

& = SUM from {1<=i<=n} p sub i y sub i

  그런데 Y가 최적해이므로 ∑pizi>∑piyi 일 수 없다. 결국 ∑pizi=∑piyi인데
X=Z이면 X도 역시 최적해이다. X≠Z라면 위의 과정을 반복하면 결국 총 이
득을 바꾸지 않고 Y를 X로 바꿀 수 있다. 결국 X는 최적해이다.

  위 증명은 Greedy 알고리즘에서 자주 사용되고 있는 증명방법을 보여주고
있다. 즉, Greedy 알고리즘이 출력하는 해 X가 최적해가 아니라고 가정한 후
에 다른 최적해로부터 목적함수 값을 변화시키지 않고 X를 만들 수 있기 때
문에 X가 최적해가 아니라는 것은 모순이라는 것을 보이는 방법이다.

5. 퇴각 검색법

  알고리즘을 설계 할 때 종종 가능한 모든 경우를 검사해야만 할 때가 있다.
그런데 가능한 경우의 갯수가 많을 때 문제가 된다. 예를 들어 NP-complete
문제를 해결하기 위해서는 현재까지 알려진 바에 따르면 입력의 갯수에 대한
지수함수 개의 경우를 모두 검사 할 수밖에 없다. 이와 같이 모든 가능한 경
우를 검사해야만 할 때, 본 절과 다음 절에서 다루는 퇴각 검색법
(backtracking)과 분기와 한정법(branch-and-bound)은 어떻게 하면 효율적으
로 검사할 수 있는가에 관한 방법론이다.

  퇴각 검색법이나 분기와 한정법은 해(solution)가 (x1,x2,...,xn) 형태의
n-tuple로 표현될 수 있는 문제에 적용 가능하다. 이 때 xi는 특정한 유한집합
Si의 원소이다(1≤i≤n). 또한 가능한 모든 경우의 tuple의 집합을 해공간
(solution space)이라 한다. 즉, 해는 해공간에 속하게 도니다. 동일한 문제에
대해 여러 가지 해공간이 있을 수 있고 문제에 따라서는 길이가 서로 다른
tuple로 해공간이 아루어져 있을 수도 있다. 이에 대한 예는 뒤에서 제시하겠
다.

  해공간과 함께 또 다른 중요한 요소는 P(x1,...,xn)으로 표현되는 기준 함수
(criterion function)이다. 여기서 (x1,...,xn)은 해공간의 tuple이다. 해공간의
tuple 중에서 주어진 기준함수를 만족하거나 혹은 최적화시키는 tuple이 해로
정의되며 해를 한 개 혹은 모두 찾는 문제에 퇴각 검색법과 분기와 한정법이
이용된다.

  해를 찾기 위해 제일 간단한 방법은 |S1||S2|...|Sn|개의 모든 tuple을 검사해
보는 것이다. 그러나 이것은 대부분의 경우 매우 효율이 낫기 마련이다. 퇴각
검색법이나 분기와 한정법은 해공간을 이루는 모든 tuple을 검사하는 순서를
적절히 조정함으로써 해공간의 모든 tuple을 다 검사하지 않아도 되도록 하는
방법이다. 이를 위해 퇴각 검색법에서는 한계함수(bounding function)를 사용
한다. 한계함수 Bk(x1,...,xk)는 완전한 tuple이 아니고 부분 tuple인 (x1,...,xk)
만 검사함으로써 해공간의 모든 tuple중 (x1,...,xk)를 부분 tuple로 가지는 모
든 tuple을 더 이상 검사해야할지 여부를 결정할 수 있게 해준다.

  퇴각 검색 알고리즘을 설계할 때 해공간을 구성하는 모든 tuple과 부분
tuple을 상태공간트리(state space tree)로 개념적으로 구성하면 편리하다. 여
기에서 개념적이라는 말은 실제 알고리즘에서 상태공간트리를 구성하는 것이
아니고 알고리즘 설계시 개념적인 도구가 되는 트리라는 말이다. 상태공간트
리의 루트 노드는 null tuple을 나타내고 깊이가 i인 노드와 i+1인 노드 사이
의 에지는 xi값의 가능한 경우를 나타낸다.

  다음 문제를 예로 들어 상태공간트리와 이에 따른 퇴각 검색법을 설명한다.

문제 (n-queen) 가로, 세로 각각 n칸으로 구성된 서양 장기판이 있다. 여왕
은 자기와 동일한 행, 열, 대각선 방향으로 공격할 수 있다. 서로 공격할 수
없도록 n개의 여왕을 배치할 수 있는 위치를 찾아라.

  이 문제에 대해 n=4일 경우를 고려해 보자. 동일한 행에는 한 개의 여왕만
놓을 수 있으므로 원하는 해를 (x1,x2,x3,x4)로 표시할 수 있다. 여기서 x1은 i
번째 행에 있는 여왕이 xi번째 열에 있다는 뜻이다(1≤i≤4). 또한 동일한 열
에도 여왕이 한 개만 있어야 하므로 해공간을 4!=24개의 tuple로 구성할 수
있고 이를 나타내는 상태공간트리는 그림 4에서처럼 된다. 여기서 모든 잎노
드(leaf node)가 해공간에 해당된다.

  해공간의 tuple은 길이가 서로 다를 수도 있는데 예를 들면 다음 문제에 대
한 해공간이 그러하다.

문제 (부분집합의 합(sum of subset)) n개의 양의 정수의 집합
W={w1,w2,...,wn}과 양의 정수 M이 주어졌을 때 W의 부분집합 중에서 그 원
소의 합이 M인 것을 찾아라.

  이 문제의 경우 해는 W의 부분집합이므로 (x1,...,xk)의 형태로 나타낼 수 있
다(1≤k≤n). 이 때 xi=j라면 W를 정렬했을 때 j번째 원소인 wj가 부분집합에
포함된다는 뜻이다. 이를 나타내는 상태공간트리는 그림 5에서처럼 된다. 이
경우는 트리의 모든 노드가 해공간에 해당되고 tuple의 길이기 일정하지 않은
경우를 표현한다.

  위 문제에 대해 고정된 길이의 tuple인 (x1,...,xn)으로 해공간을 설계할 수도
있다. 이 경우 xi=1이면 wi가 부분집합에 포함되는 것을 나타내고 xi=0이면 포
함되지 않는 것을 나타낸다. 이를 나타내는 상태공간트리는 그림 6에서처럼
된다. 이 경우에는 트리의 잎노드만이 해공간에 해당된다.

  상태공간트리의 입장에서 보면 퇴각 검색법은 상태공간트리를 루트로부터
깊이 우선 탐색을하면서 그 순서대로 해공간에 속하는 tuple을 검사해 나간다.
탐색도중의 현재 노드를 Z라하자. Z가 해공간에 해당되는 노드가 아니더라도
현재 상태의 부분 tuple만 가지고 Z의 부트리(subtree)를 더 이상 탐색하지 않
아도 된다고 판단할 수 있는 경우가 있다. 이 때 사용되는 것이 한계함수인데
이는 문제에서 주어진 기준함수로부터 설계된다.

  요약해서 말하면 퇴각 검색법은 한계함수를 사용하면서 상태공간트리를 깊
이 우선 탐색하며 해를 찾는 방법이라 할 수 있다.

  n-queen 문제에서 상태공간트리를 그림 4처럼 설계했을 경우 한 노드 Z에
해당되는 부분 tuple을 (x1,...,xk)라 할 때 이는 첫번째 여왕부터 k번째 여왕까
지의 위치가 잠정적으로 결정되었다는 것이고 이들이 서로 대각선으로 공격하
는지 여부를 검사하는 것이 한계 함수가 된다. 그림 4에서 빗금친 노드와 검
게 표시된 노드가 탐색되는 노드를 나타내며 특히 검게 표시된 노드는 한계함
함수에 의해 더 이상 탐색되지 않는다고 판단되는 노드이다.

  이 예에서 볼 수 있듯이 한계 함수를 적절히 사용하면 방문되는 노드의 수
를 크게 줄여 전체 효율을 높일 수 있다. 이 때 실제로 전체 트리가 만들어
지는 것이 아니고 트리는 알고리즘의 동작 순서를 개념적으로 보여주고 있을
뿐이다. 즉, 그림 4에서 빗금친 노드와 검게 표시된 노드만이 실제 고려되는
부분이다.

  다음 알고리즘은 퇴각 검색법의 일반적인 골격을 보여주고 있다. 이 때
(x1,...,xk)는 상태공간트리의 루트로부터 현재 노드 Z까지의 경로에 해당되는
부분 tuple이고, Z와 자식노드를 연결하는 에지에 해당되는 모든 xk+1 값을
T(x1,...,xk)로 표현하며 Bk(x1,...,xk)는 Z에서 한계함수 값이다. Bk(x1,...,xk)값이
false일 경우 더 이상 부트리를 검사하지 않아도 된다는 뜻이다. 또한 X는 전
역변수로서 현재 상태 부분 tuple인 (x1,...,xk)를 X(1),...,X(k)에 저장한다. 그리
고 처음에는 Backtrack(1)을 호출함으로서 시작한다.

Algorithm Backtrack(k)

입력: 상태공간트리에서 현재 노드의 깊이 k

출력: 현재 노드가 해에 해당되는 tuple이면 이를 출력한다.

1. for each X(k)∈T(X(1),...,X(k-1)) do

2.     if Bk(X(1),...,X(k))=true then

3.         (X(1),...,X(k))가 해이면 이를 출력한다.

4.         Backtrack(k+1)

5.     endif

6. endfor

7. end Backtrack

  위 알고리즘은 기준함수를 만족하는 모든 해를 출력하는 알고리즘인데 만일
기준함수를 만족하는 최적해를 찾아야 한다면 단계 3을 수정하면 된다.

  퇴각 검색법의 효율은 일반적으로 X(k)를 생성하는데 소요되는 시간과 이
의 가능한 갯수, 한계함수를 계산하는데 소요되는 시간, 한계함수를 만족하는
노드의 숫자 등의 요소에 좌우된다. 또한 대부분의 경우 퇴각 검색 알고리즘
의 시간 복잡도를 예측하는 것은 매우 어려운 일이며 알고리즘의 조그만 수정
이 전체 수행시간에 큰 영향을 미치는 것으로 알려져 있다.

  앞으로 한계함수를 설계하는 두 가지 예를 제시하겠다. 먼저 앞에서 제시했
던 부분집합의 합 문제에서 상태공간트리가 그림 6처럼 될 경우 가장 간단한
함계함수 Bk(x1,...,xk)로서 다음의 경우에 true인 함수를 설계할 수 있다.

SUM from {1<=i<=k} w sub i x sub i + SUM from {k+1<=i<=n} w sub i
<= M

  W의 원소가 크기가 증가하는 순서로 정렬되어 있다고 하자. ∑1≤i≤kwixi≠
M이면 다음의 경우에 true인 함수를 함께 사용하여 한계함수의 효율을 높일
수 있다.

SUM from {1<=i<=k} w sub i x sub i + w sub {k+1} <= M

  마지막으로 기준함수를 만족하는 최적해를 찾는 문제의 경우 최적해의 상한
값(upper bound) 혹은 하한값(lower bound)을 사용하여 한계함수를 설계하는
예를 제시한다. 이를 위해 다음 문제를 고려한다.

문제 (0/1-Knapsack) 앞에서 제시한 Knapsack 문제와 동일하나 xi∈{0,1}이
어야 한다.

  이 문제는 잘 알려진 NP-complete 문제로서 상태공간트리를 부분집합의 합
문제와 유사하게 그림 6에서와 같이 구성할 수 있다. 이 경우 한계함수로서
현재 상태에서 부트리를 계속 탐색했을 경우 얻을 수 있는 최대이득에 대한
상한값을 구하고, 현재까지 구한 가능해(feasible solution)중에서 가장 이득을
많이 내는 해보다 앞에서 구한 상한값이 적게 되는 경우 한계함수의 값을
false로 하도록 설계할 수 있다. 각 물건이 Greedy_Knapsack에서처럼 pi/wi
값이 감소하는 순서로 정렬되어 있다고 가정하자. 이제 상태공간트리에서 현
재 노드가 부분 tuple인 (x1,...,xk)에 해당된다고 하자. 이 경우 x1,...,xk 값을 현
재 상태로 그대로 고정시키고 k+1≤i≤n인 xi는 0≤xi≤ i로 조건을 완화하여
Greedy_Knapsack으로 이득값을 계산한다. 그러면 그 값이 현재 상태에서 계
속 탐색했을 경우 얻을 수 있는 최대이득에 대한 상한값이 된다.

6. 분기와 한정법

  분기와 한정법(branch-and-bound)은 퇴각 검색법과 마찬가지로 상태공간트
리의 노드를 생성하며 탐색하는 방법으로서 인공지능이나 OR(operational
research) 분야에서 활발하게 연구되고 있는 분야이다.

  분기와 한정법에서 어떻게 상태공간트리의 노드를 생성하고 탐색하는지 설
명하기 위해 먼저 다음을 정의한다. 상태공간트리의 한 노드가 이미 생성되어
있으나 모든 자식 노드가 아직까지 생성되어 있지 않고 있을 때 그 노드를
유효(live) 노드라 하자. 만일 어떤 노드 X의 자식 노드를 모두 다 생성하지
는 않았지만 X의 부트리를 더 이상 생성하지 않아도 된다고 이미 알았다면
X는 유효노드가 아니라고 정의한다. 유효노드 중에서 현재 자식노드를 생성하
고 있는 노드를 E-노드라 한다.

  상태공간트리의 노드를 생성하고 탐색하는 방법 중에서 E-노드의 모든 자
식 노드가 생성된 이후에만 다른 유효노드가 E-노드가 될 수 있는 방법을 총
칭해서 분기와 한정법이라 한다. 노드를 생성하고 탐색할 때 현재 유효노드
중에서 E-노드를 골라내는 방법에 따라 FIFO(first-in-first-out)-탐색법,
LIFO(last-in-first-out)-탐색법, LC(least-cost)-탐색법 등으로 분기와 한정법
이 분류된다.

  FIFO-탐색법은 유효노드를 큐(queue)에 유지하고 LIFO-탐색법은 스택
(stack)에 유지하면서 다음 E-노드를 선택할 때 큐나 스택에서 가져온다. 따
라서 전자의 경우 상태공간트리를 너비 우선 탐색(BFS; breadth first search)
하는 것이고 후자의 경우 깊이 우선 탐색(DFS; depth first search)과 유사하
게 된다. 그런데 이 두가지 방법에서는 단순히 상태공간트리의 모양에 따라
다음 E-노드를 결정할 뿐 주어진 입력에는 무관하게 동작하므로 효율적인 방
법이라고 할 수 없다. 이러한 점을 고려한 것이 LC-탐색이다.

  LC-탐색에서는 주어진 입력에 따라 현재 상태에서의 유효노드에 우선 순위
를 부여한다. 즉, 부트리에 해가 있을 가능성이 많다고 생각되는 노드에 우선
순위를 높게 부여하여 그 노드를 제일 먼저 E-노드로 선택한다. 이를 위하여
대부분의 경우 힙(heap)을 사용하여 현재 상태의 유효노드를 유지한다. 만일
유효노드의 깊이가 작을수록 우선순위를 높게하면 FIFO-탐색이되고 부모노드
보다 자식노드의 우선순위를 높게하면 LIFO-탐색이 되므로 FIFO-탐색과
LIFO-탐색은 LC-탐색의 특별한 경우라 할 수 있다. 보통 분기와 한정법이라
하면 LC-탐색 분기와 한정법을 의미한다. 앞으로 LC-탐색에 대해서만 고려
하기로 한다. 또한 상태공간트리의 해공간은 항상 잎노드에만 해당되는 경우
에 대해서만 설명한다. 그렇지 않은 경우에는 앞으로 설명할 방법을 조금만
수정하면 된다.

  퇴각 검색법과 마찬가리조 분기와 한정법은 해가 tuple의 형태로 표시될 수
있을 경우에 사용된다. 특히 목적함수(objective function) 값을 최소 혹은 최
대로 하는 최적해를 찾는 문제에 주로 사용된다. 목적함수의 값을 최대로 하
는 문제는 함수의 부호만 바꾸면 최소화하는 문제가 되므로 앞으로 최소화 문
제만 고려한다.

  분기와 한정법에서 현재 유효노드중에서 다음 E-노드를 선택하기 위한 우
선순위를 결정하기 위해 다음과 같은 비용함수(cost function)를 정의한다. X
를 상태공간트리의 노드라 하자. X가 가능해(feasible solution)에 해당되는 잎
노드이면 이에 대한 목적함수 값이 c(X) 값이 된다. 만일 X가 잎노드이면서
가능해에 해당되지 않으면 c(X)=∞이다. X가 잎노드가 아니면 c(X)는 X를 루
트로 하는 부트리에 속하는 노드의 비용함수 값 중에서 가장 작은 값이다.

  LC-탐색에서 현재 유효노드 중에서 비용함수 값이 가장 작은 노드를 E-노
드로 선택하면 항상 최소 갯수의 노드만을 생성하면서 최적해에 해당되는 노
드에 다다를 수 있다. 그런데 문제를 해결하기 전에 미리 비용함수 값을 알아
낼 수 없으므로 c(X)의 근사 추정 값인 (X)를 사용한다. 만일 (X)값이 c(X)
값과 큰 차이가 없다면 매우 효율적으로 노드를 생성하면서 상태공간트리를
탐색할 수 있다. 다음은 분기와 한정법의 골격을 보여주고 있다.

Algorithm BB(T, )

입력: 상태 공간트리의 루트 노드 T

      비용함수의 근사 추정함수

출력: 최적해에 해당되는 노드

1. E:=T

2. 유효노드를 위한 리스트를 초기화한다.

3. 만일 E가 가능해에 해당되면 루트로부터 E까지의 경로를 최적해에 출력하
고 종료한다.

4. E의 모든 자식노드를 현재의 유효노드의 리스트에 집어 넣는다.

5. 만일 현재 유효노드가 한개도 없으면 해가 없다는 것을 출력하고 종료한다.

6. 현재의 유효노드 리스트에서 ()값이 가장 작은 노드를 선택하여 이를 E로
설정한다.

7. goto 3

end BB

  위 알고리즘에서 유효노드 리스트는 보통 힙(heap)을 사용한다. 또한 모든
노드 X에 대해 X가 가능해지면 (X)=c(X)이고 그렇지 않은 경우 (X)≤c(X)
이면 이 알고리즘은 항상 최적해를 찾는다는 것이 보장된다. 만일 E가 단계 3
에서 찾는 가능해에 해당되는 노드라면 현재의 다른 유효노드 X'에 대해
c(E)=(E)≤(X')≤c(X')이기 때문이다.

  이제 (X)를 설계하는 방법에 대해 고려해 보자. X가 부분 tuple (x1,...,xk)
를 잠정적으로 결정했기 때문에 c(X)에서 이에 해당되는 값은 쉽게 알 수 있
다. 이를 g(X)라 하자. 이를 이용하면 c(X)=g(X)+h(X)의 형태로 나타낼 수 있
다. 즉, X를 루트로 하는 부트리에서 가능해에 해당되는 노드중 목적함수 값
이 가장 작은 노드를 Z라 할 때 h(X)는 X에서 Z까지 가기 위한 추가 비용이
다. 따라서 (X)를 구하기 위해서는 g(X)값은 이미 알고 있으므로 h(X)값만
예측하면 된다. 이를 (X)라 하면 (X)를 다음과 같이 나타낼 수 있다.

(X)=g(X)+(X)

  만일 (X)≤h(X)이면 (X)≤c(X)이므로 알고리즘 BB는 항상 최적해를 찾
게 된다.

  이제까지 살펴본 바와 같이 분기와 한정 알고리즘을 설계할 때 가장 핵심이
되는 것은 (X)≤h(X)인 예측값 (X)를 구하는 것이다. 또한 c(X)-(X) 값이
작을수록 알고리즘이 효율적으로 동작한다는 것이 알려져 있다. 따라서 분기
와 한정 알고리즘의 효율을 높이기 위해서는 (X)≤h(X)이면서 h(X)값에 가
장 근접한 (X)를 찾는 것이 중요하다. 다음 문제에 대해 (X)값을 구하는 예
를 보인다.

문제 (15-puzzle) 15개의 타일과 한개의 빈 공간이 그림 7처럼 4×4 구역에
배치되어 있다. 한번에 한개의 타일만을 움직여서 그림 7에서처럼 정렬하고
싶다. 정렬이 가능한 임의의 배치가 주어졌을 때 타일을 움직이는 횟수를 최
소로 하면서 이를 정렬하는 순서를 찾아라.

  이 문제에서 타일을 움직이는 것 대신 빈 공간을 움직인다고 생각해도 되므
로 현재 상태에서 빈 공간이 움직이는 네 방향에 따라 최대 네개의 자식노드
를 할당하는 상태공간트리를 구성할 수 있다. 상태공간트리의 한 노드 X에 대
해 g(X)를 타일이 이제까지 움직인 횟수로 하면 된다. 그리고 제자리에 있지
않은 타일(빈공간 제외)dmf 제자리로 보낼려면 적어도 한번은 움직여야 한다.
따라서 (X)를 현재 상태에서 제자리에 있지 않은 타일의 갯수로 할 수 있다.
이 경우 (X)≤h(X)라는 것은 자명하다. 이와 같이 (X)를 정의하였을 경우
그림 8은 알고리즘 BB가 생성하는 노드와 수행 순서를 보여주고 있다. 이때
각 노드의 (X) 값은 g(X)+(X)의 형태로 표시되어있고 E-노드로 선택되는
순서가 괄호안의 번호로 표시되어 있다.

  분기와 한정법에서도 퇴각 검색법의 경우에서처럼 한계함수를 사용할 수 있
다. 보통 주어진 문제의 최적해에 대한 상한값(upper bound)을 한계함수로 사
용한다. 이를 U라 하자. 처음에는 U=∞로 하고 탐색을 하면서 U값을 보정한
다. 예를 들어 X를 현재 E-토드라 할때 X의 부트리에있는 가능해를 예측하
여 이에 대한 목적함수 값을 얻거나 혹은 다른 방법으로 최적해에 대한 상한
값을 얻는다. 이를 u(X)로 하자. 탐색 도중에 만일 u(X)<U이면 U를 u(X)로
새롭게 보정한다. 알고리즘 BB의 단계 4에서 현재 E-토드의 자식노드 Y를
생성할 때 (Y)>U이면 Y를 유효노드 리스트에 집어 넣지 않아도 된다. 왜냐
하면 (최적해의 목적함수 값)≤U<(Y)≤c(Y)이기 때문이다. 다음 문제에 대
해 (X)와 u(X)를 설계하는 예를 보인다.

문제 (기한부 스케줄링) 한개의 CPU를 사용하는 n개의 작업(job)이 있다. 각
작업 i에 대해 세가지 조건 (pi,di,ti)가 주어지는데 이는 다음을 의미한다. 작업
i는 ti 시간이 소요되는데 작업이 완료되어야 하는 시각 di까지 완료되지 못하
면 pi만큼의 벌칙이 부과된다. 모두 완료시간 안에 끝낼 수 있는 작업을 선택
하되 선택되지 않은 작업들에 의해 부과되는 벌칙의 합이 최소가 되도록 하
라.

  이 문제에 대해 상태공간트리를 그림 6처럼 구성할 수 있다. 현재 노드를 X
라 하고 X에 대한 부분 tuple을 (x1,...,xk)라 하면 (X)를 다음과 같이 간단히
설계할 수 있다.

& g(X)=SUM from {1<=i<=k} p sub i (1-x sub i )#

& hat h (X)=0

  이 경우 (X)≤c(X)인 것은 명백하다. 물론 (X) 값을 좀 더 효율적으로 설
정하는 것은 어려운 일이 아니다. 이제 u(X)를 다음과 같이 설계할 수 있다.

u(X)= SUM from {1<=i<=k} p sub i (1-x sub i ) + SUM from {k<=i<=n}
p sub i

  이를 이용하면 상한값 U를 유지하면서 탐색할 수 있다.

7. 무작위 알고리즘

  알고리즘의 진행 도중에 현재 상태에서 앞으로 어떤 방향으로 계속진행해야
할지 판단을 내려야 할 때가 있다. 가능성이 있는 여러 선택 방향 중에서 한
개를 선택할 때 난수(random number)를 사용하여 무작위로 선택하여 수행을
계속하는 알고리즘을 총칭하여 무작위 알고리즘(randomized algorithm)이라
한다.

  다음 경우를 고려해 보자. 선택을 해야하는 시점에서 선택의 가능성이 매우
많이 있어 그 중에서 최선의 선택을 하기 위해서는 많은 비용 즉, 많은 수행
시간이 사용된다고 하자. 이 때 무작위로 선택하면 수행시간은 빨라지게 되나
나쁜 선택을 할 가능성이 있다. 만일 전자의 경우에 의해 수행 시간에서 이득
을 얻을 가능성이 후자에 의해서 손해를 볼 가능성보다 크다면 무작위 알고리
즘은 매우 유용하다.

  또 다른 면을 고려해 보자. 일반적으로 평균 시간복잡도(average-case time
complexity)를 분석 할 때 입력의 확률분포를 가정한다. 그런데 실제 입력이
항상 그 가정을 따르지 않는 경우가 많다. 이 경우 무작위 알고리즘을 설계할
수 있으므로 유용하다. 예를 들어 퀵정렬(quick sort) 알고리즘과 같이 입력이
무작위로 섞여 있을 때 효율적으로 동작하는 알고리즘이 있다고 하자. 이 경
우 어떤 입력이 주어지더라도 일단 그 입력의 순서를 무작위로 섞어 놓고 정
렬을 하도록 무작위 알고리즘을 설계하면 입력의 확률 분포에 무관하게 효율
적인 시간 복잡도가 보장될 것이다.

  무작위 알고리즘의 특징은 입력자료 뿐만이 아니라 난수발생기의 동작에 의
해서 알고리즘의 수행이 좌우 된다는 것이다. 따라서 똑같은 입력이 주어진다
고 하더라도 난수발생기의 동작에 의해 매번 서로 다르게 동작한다. 그러나
출력은 항상 동일하다. 이러한 이유로 인해 최악의 경우의 입력이라는 것은
있을 수 없고, 난수 발생기가 최악의 난수를 만들어 낼 때가 최악의 경우이다.

  다음의 알고리즘은 퀵정렬을 무작위 알고리즘으로 수정한 것인데 난수발생
기가 최악의 경우를 거의 만들어 내지 않는 예를 보이고 있다. 다음의 알고리
즘에서 Random(i,j)는 i이상 j이하의 정수를 발생시키는 난수발생기이고 A는
초기에 n개의 입력을 가지고 있는 전역 변수(global variable)이다. 처음에는
Randomized_Quicksort(1,n)을 호출함으로써 수행이 시작된다.

Algorithm Randomized_Quicksort(first,last)

입력: A(first..last)

출력: A(first..last)가 정렬되어 재배치된 것

1. 만일 first≥last이면 종료한다.

2. pivot:=Random(first,last)

3. A(first..last)에서 A(pivot)의 위치를 찾고 A(first..pivot-1)의 값이
A(pivot+1..last) 값보다 작도록 A(first..last)를 재배열한다.

4. Randomized_Quicksort(first,pivot-1)

5. Randomized_Quicksort(pivot+1,last)

end Randomized_Quicksort

  위 알고리즘에서 입력을 두 부분으로 나누는 위치를 선택할 때 난수발생기
를 이용하고 나머지 부분은 퀵정렬과 동일하다. Randomized_Quicksort는 최
악의 수행시간과 평균 수행시간이 각각 O(n2)과 O(nlogn)으로 무작위 알고리
즘이 아닌 경우와 동일하다. 그러나 최악의 경우를 만들어 낼 가능성이 매우
희박한데 좀 더 자세히 살펴보면 n!개의 입력조합 중에서 수행시간이
O(nlogn)이 아닌 경우 확률이 O(1/nk)이다. 이때 k는 임의의 양수이다.

  마지막으로 무작위 알고리즘이 효율적으로 동작하는 다른 예를 제시한다.

문제 (리스트 탐색) 길이가 n인 연결 리스트(linked list)가 두 배열 key(1..n)
과 next(1..n)을 사용하여 표현되어 있다. 이때 key는 자료를 나타내는 부분이
고 next는 연결을 나타내는 부분이다. 또한 1≤i≤n이고 next(i)≠nil인 모든 i
에 대해 key(i)<key(next(i))이다. 임의의 입력 k가 주어졌을 때 key(i)=k인 i
를 찾아라.

  이 문제에 대해 다음과 같은 무작위 알고리즘을 설계할 수 있다.

Algorithm Randomized_Search(head,k)

입력: 연결 리스트의 시작위치 head

     위치를 찾으려는 값 k

출력: 연결 리스트에서 k의 위치

1. i:=head

2. while i≠nil and key(i)≤k do

3.     if key(i)=k then return i

4.     j:=Random(1,n)

5.     if key(i)<key(j)<k then i:=j

6.     i:=next(i)

7. endwhile

end Randomized_Search

  이 알고리즘에서 만일 단계 4와 5를 제외하면 연결 리스트를 탐색하는 가장
근본적인 알고리즘이 되고 최악 수행시간과 평균 수행시간이 모두 O(n)이 된
다. 이 알고리즘에서는 난수발생기를 이용하여 탐색 위치를 건너뛸 수 있도록
하는 단계를 추가하여(단계 4,5) 무작위 알고리즘이 되었다.
Randomized_Search의 평균 수행시간은

O(ROOT n )

으로 무작위 알고리즘이 매우
효과적인 예를 보이고 있다.

  확률 알고리즘(probabilistic algorithm)

  다음과 같은 질문에 답하는 3종류의 컴퓨터 A,B와 C를 생각해 보자. 각각
의 컴퓨터는 같은 질문에 대해 연속적으로 답을 제시한다.

"언제 콜럼부스(Columbus)가 미국(America)을 발견했는가?"

  ■ A: 1480∼1490년 사이에 발견했을 확률은 80%입니다.

     A: 1470∼1490년 사이에 발견했을 확률은 90%입니다.

     ...

  ■

  다음은 알고리즘 설계 및 분석 분야에서 자주 참고되는 문헌이다. 본 고에
서는 그 중에서 [Aho-Hopcroft-Ullman 83], [Baase 88],
[Cormen-Leiserson-Rivest 92], [Horowitz-Sahni 78], [Manber 89],
[Preparata-Shamon 85]등을 참조하였다.

[1] A. V. Aho, J. E. Hopcroft, and J. D. Ullman, Data Structures and
Algorithms, Addison-Wesley, Reading, Massachusetts, 1983.

[2] A. V. Aho, J. E. Hopcroft, and J. D. Ullman, The Design and
Analysis of Computer Algorithms, Addison-Wesley, Reading,
Massachusetts, 1974.

[3] S. Baase, Computer Algorithms: Introduction to Design and
Analysis, Addison-Wesley, Reading, Massachusetts, second edition, 1988.

[4] T. H. Cormen, C. E. Leiserson, and R. L. Rivcst, Introduction to
Algorithms, MIT Press, Cambridge, Massachusetts, 1992.

[5] M. Gondran and M. Minoux, Graphs and Algorithms, John Wiley &
Sons, New York, 1984.

[6] D. Harel, Algorithmics: The Sprit of Computing, Addison-Wesley,
Reading, Massachusetts, 1987.

[7] E. Horowitz and S. Sahni, Fundamentals of Computer Algorithms,
Computer Science Press, Potomac, Maryland, 1978.

[8] T. C. Hu, Combinatorial Algorithms, Addison-Wesley, Reading,
Massachusetts, 1982.

[9] D. E. Knuth, Fundamental Algorithms, The Art of Computer
Programming, vol. 1, Addison-Wesley, Reading, Massachusetts, second
edition, 1973.

[10] J. V. Leeuwen, Ed., Algorithms and Complexity, handbook of
Theoretical Computer Science, vol. A, Elsevier, Amsterdam, 1990.

[11] U. Manber, Introduction to Algorithms: A Creative Approach,
Addison-Wesley, Reading, Massachusetts, 1989.

[12] C. H. Papadimitriou and K. Steiglitz, Combinatorial Optimization:
Algorithms and Complexity, Prentice-Hall, Englewood Cliffs, New
Jersey, 1982.

[13] F. P. Preparata and M. I. Shamos, Computational Geometry: An
Introduction, Springer-Verlag, New York, 1985.

[14] R. Sedgewick, Algorithms, Addison-Wesley, Reading, Massachusetts,
1983.

[15] M. N. S. Swamy and K. Thulasiraman, Graphs, Networks, and
Algorithms, John Wiley & Sons, New York, 1981.

좌경룡

1971	서울대학교 전기공학과를 졸업하고, 노스웨스턴대학교 전기 및 전산

    학과에서 석사(1977)와 박사(1980)학위를 받았음. 현재 한국 과학기술

    원 전산학과 교수로 근무중.

관심 분야: 그래프 이론, 알고리즘 설계 및 해석, 계산기하학, 시스템 고장진단

  실습문제

1.


45. 정리의 증명 방법
(proof technique for
theorem)

  정리의 증명 방법■

             본 내용은 필자의 대학원 시절(1997∼) 지도 교수님을 맡으셨던 경북대학
          교 컴퓨터공학과 김삼묘 교수님의 학부 오토마타 강의노트의 일부를 토대
          로 하여 정리한 것입니다.

예비지식

  형식 언어(formal language)는 자연어(natural language)처럼 언어
(language)이지만, 완전히 형식적이다. 자연어가 문법(grammar)을 가지고, 단
어(word)를 가지듯이 형식 언어도 문법을 가지고, 단어를 가진다. 하지만 자
연어는 형식적이지 않으므로, 어느 정도 틀린 문법과 단어를 사용하더라도 사
람들은 자연어를 이해할 수 있다(사람은 얼마나 강력한 하나님의 창조물인
가!). 또한 자연어는 계속해서 변화한다. 독자들은 한국어라는 자연어를 사용
하고 있으며, 영어라는 자연어를 알아듣기 위해 많은 시간을 투자한다. 하지만
여전히 영어라는 자연어를 이해하는 것은 어려운 일이다.

  하지만 형식 언어는 그렇지 않다. 한 번 정의된 문법은 바뀌지 않고 - 사람
들이 문법을 바꾸어 새 판을 내 놓지 않는 한 - 단어가 새로 추가되는 일은
없다. 또한 너무나 형식적이어서 점(period) 한 개라도 틀린 문법을 허용하지
않는다. 독자들이 접할 수 있는 잘 알려진 형식 언어에는 C, C++, Pascal,
Basic등이 있다.

  본 장에서는 이러한 형식 언어의 기본 이론들과 증명의 방법들에 대해서 기
술한다. 독자들은 영어 문장 "Do believe THE God!"를 이해할 것이다. 이것
은 C++의 스트링으로 표현된다. 실제로 C++소스 프로그램은 하나의 긴 스트
링이다. 그러므로 형식 언어는 스트링을 다루는 이론이다. C++에서 스트링을
이루는 각각의 문자를 문자(character)라고 하지만, 형식 언어에서는 심벌
(symbol)이라 한다. 또한 Do, believe 등은 영어의 단어인데, 형식 언어에서
도 단어(word)라 한다. "Do believe THE God!"는 3형식 문장으로 S+V+O의
문법을 가진다. 형식 언어도 나름대로의 문법을 가지는데 변수를 선언하는 문
법, 문장의 문법 등이 있다. 독자들은 변수를 선언하는 문법 int i,j; 와 문장
return; 등을 알고 있을 것이다. 이것은 엄격한 문법에 의해서 표현된다.

  하지만, 이 장에서는 먼저 정수를 구성하는 스트링, ID를 구성하는 스트링
처럼 정규 문법(regular grammar)에 의해 표현 가능한 스트링부터 다룬다.
예를 들면 실수의 정규 문법은 정규 표현(regular expression)
{0,1,...,9}*.{0,1,...,9}*으로 나타낼 수 있다■.

             DOS나 Win95등에서 사용하는 와일드 카드(wild card)는 정규표현에서 아
          이디어를 얻은 것이다. '오토마타'를 참조하라.

  C++ 변수의 선언 등은 문맥 자유 문법(CFG; context free grammar)에
의해서 표현 가능한데, 'CFG'를 참조하라.

표기관례■

             아래의 사항은 '오토마타'에서 언급한 바 있다.

□ 앞 부분의 영어 알파벳 소문자 a, b, c, ...는 심벌(symbol)을 나타낸다.

□ 뒷 부분의 영어 알파벳 소문자 ..., u, v, w, x, y, z는 심벌의 스트링
(string)을 나타낸다.

□ 영어 알파벳 대문자와 그리스 문자 Σ, Γ등은 집합(set)을 나타낸다.

□ 스트링 x에 대해, |x|는 x의 길이(length)를 나타낸다.

□ ε 혹은 λ는 널 스트링(null string)을 나타낸다.

□ ∑*는 ∑의 클린 스타(Kleene star) 혹은 클로저(closure)라 하며, 알파
벳(alphabet■) ∑로 표현 가능한 모든 문자열의 집합을 의미한다. 여기에는 널

             여기서의 알파벳은 영어 알파벳과는 구분되어야 한다. 형식언어(formal
          language)에서 알파벳은 심벌을 원소로 가지는 집합 ∑를 의미한다. 즉, 스
          트링을 구성하는 모든 심벌들을 가지는 집합이다.

스트링도 포함되며, ∑+=∑*-{ε}이다.

□ ai는 심벌 a가 i개 연속으로 결합된 스트링을 의미하며, xR은 스트링 x의
역(reverse)을 나타낸다. 예를 들면 (abcd)R=dcba이다.

집합 표현

명확한(explicit) 표현: A={2,8}, ∑={a, b, c}

조건 제시법(by set properties): A={x | property list of x}

예) A={x|x∈{a,b}* and x has no consecutive a's}

    B={n|n is an even number}

    C={xxR|x∈{0,1}+}

    D={anbncn|n≥0}

증명방법

  명제 논리(implication) "if P then Q"(P⇒Q로 쓴다)의 진리표(truth
table)는 아래와 같이 정의된다. 여기서 P를 전제(premise), Q를 결론이라 한
다.

P  Q

P⇒Q

F  F

F  T

T  F

T  T

T

T

F

T

             가정이 참이면서 결론이 거짓인 경우만 명제는 거짓이된다.

  명제(proposition)는 참(true) 혹은 거짓(false)인 선언적인 문장(declarative
statement)이다. 우리는 다음과 같은 명제의 증명 방법에 대해 흥미가 있다.

P⇒Q or P⇔Q(P⇒Q and Q⇒P)

  정리(theorem)를 증명하기 위해 사용할 수 있는 도구는 다음과 같은 것이
있다.

    ① 공리(axiom)

    ② 이미 증명된 정리

    ③ 증명 과정 중에 유도한 새로운 사실

1. 자명한 증명(trivial proof): 전제 P와 상관없이, 결론 Q가 항상 참임 - 예
를 들면 동어 반복(tautology) - 을 보인다. "내일 지구가 멸망한다면, 독자들
은 이 글을 보고 있다."는 가정에 상관없이 항상 참이다(비록 내일 지구가 멸
망한다하더라도...). 필자는 이 증명을 사오정 증명 - 사오정 시리즈가 유행하
는 요즘 - 이라 부르고 싶다.

2. 무의미 증명(vacuous proof): Q와 상관없이 P가 항상 거짓임을 보인다.
진리표에서 전제(premise)가 거짓이면 명제는 항상 참이므로 이것은 가능하
다. "독자들이 이 글을 읽지 않는다면, 이 책은 베스트셀러이다."는 명제는 항
상 참이다. 왜냐하면, 독자들은 벌써 이 책을 읽었으므로, 가정이 항상 거짓이
기 때문이다. 그러나 안타깝게도 P⇒Q가 참이지, Q가 참인 것은 아니다.

3. 직접 증명(direct proof): P가 참이면, Q도 역시 참임을 보인다.

  비둘기집의 원리(pigeonhole principle)

4. 간접 증명(indirect proof): 명제의 대우(contrapositive)는 항상 참이므로,
P⇒Q의 대우 ~Q⇒~P를 증명한다.

5. 경우에 의한 증명(proof by cases): (P1∨P2∨...∨Pn)⇒Q의 증명은 P1⇒Q,
P2⇒Q, ... , Pn⇒Q의 모든 경우(case)를 증명한다. 왜냐하면, (P1∨P2∨...∨Pn)
⇒Q ⇔ (P1⇒Q)∨(P2⇒Q)∨...∨(Pn⇒Q) 이기 때문이다. 비슷한 경우가 많은
경우 한두 가지를 자세히 기술하고, 나머지는 약술한다.

6. 모순에 의한 증명(proof by contradiction): P가 참일 때 Q를 거짓이라
가정하여, 모순을 유도한다. 이것은 P⇒Q에서 Q가 거짓이라는 가정이 잘못된
것이므로, P⇒Q는 항상 참이다(Q가 참이면 P⇒Q는 항상 참이다).

7. 존재 증명(existence proof):

8. 반례에 의한 증명(proof by counter example):

9. 일반화에 의한 증명(proof by generalization):

10. 귀납적 증명(proof by induction):

11. 구성에 의한 증명(proof by construction):

  실습문제

1.


46. BGI 그래픽 라이브러리

46. BGI 그래픽 라이브러
리

  그래픽 라이브러리(graphic library)는 특정 언어의 컴파일러가 지원하는
그래픽을 위한 함수의 모음을 말한다. 이 장에서는 볼런드의 C/C++ 컴파일러
가 지원하는 DOS용 그래픽 라이브러리인 BGI(Borland graphic interface)
에 대해서 다룬다.

  왜 BGI인가?

  DOS 운영체제의 업그레이드(upgrade)가 중단된 지금 BGI는 의미가 없어진
것이 사실이다. 하지만, 운영체제와 무관하게, 그래픽 라이브러리의 함수를 이
용하여 프로그램하기 위해서, 그리고 비교적 크기가 작은 라이브러리를 선택
할 필요성에 의해 BGI를 선택했다. 독자들은 윈도우의 GDI(graphic device
interface) 혹은 DirectX - GDI의 느린 속도를 극복하기 위한 윈도우의 그래
픽 라이브러리 군 - 를 이용하기 위해서는 윈도우 프로그래밍 기교를 먼저
익혀야 한다. 하지만, BGI는 어떤 선험적인 프로그래밍 경험이 필요 없다. 다
만 C만 알고 있으면 된다.

  독자들이 주의할 점은, 이 책이 BGI를 선택한 이유는 단지, 그래픽 기능을
설명하기 위해서라는 것이다. 만약 그래픽을 이용하는 거대한 프로그램을 만
들 계획이라면 BGI를 선택해서는 안 된다.

  하지만, BGI와 상관없이 - 비록 BGI 라이브러리를 이용하지만 - 이 책에서
사용된 모든 그래픽 기교는 익혀둘 만한 것이다(실제로 필자의 경험으로 볼
때 BGI에서 익힌 기교는 GDI를 익히는데 많은 도움이 되었다. 사실상 BGI에
서 지원하는 모든 함수는 이름만 다를 뿐 GDI에서 모두 지원하므로, BGI에서
GDI로 옮겨 갈 때 그래픽 패러다임(paradigm)의 이동은 비교적 적다).

  그래픽 함수의 종류

  그래픽 함수들이 지원해야 하는 함수의 종류는 'BGI와 GDI의 비교'에 자세
히 기술하였다. 여기서는 BGI가 지원하는 함수를 기능별로 분류해 보자.

  먼저, 화면에 선을 그리는 함수를 생각해 보자. 명시되어야 하는 것은 선의
시작점, 선의 끝점, 선의 색, 선의 굵기, 선의 모양 등이다. 이중 선의 시작점
과 선의 끝점은 선을 기술하기 위해서 반드시 필요한 부분이지만, 선의 색, 선
의 굵기, 선의 모양 등은 선을 기술하기 위한 선택 사항들이다. 이처럼, 그리
기 함수의 선택 사항들을 속성(attribute)이라 한다. 선의 속성으로는 색
(color), 굵기(width)와 모양(style)등이 있으며, 사각형(rectangle)을 그리는 함
수의 속성은 선 모양, 면 모양(pattern), 선 색, 면 색, 선 굵기 등이 있을 수
있다.

  아래에 구분된 함수는 모든 BGI함수를 열거하지 않았다. 자세한 사항은 볼
런드 컴파일러의 도움말을 참조하라. 또한 모든 그래픽 함수의 기능을 모두
이해하려 하지 말라. 필요한 경우에 함수의 기능을 설명한다.

             line(x1,y1,x2,y2): 선을 그리는 경우 선의 폭(width), 형태(style)와 색(color)는
          선의 속성(attribute)이 된다. 그리기 함수의 속성을 설정하는 함수는 set으
          로 시작한다. 예를 들면 선의 속성을 설정하는 함수는 setlinestyle()이다.

  BGI 함수는 다음과 같이 크게 구분할 수 있다.

  ① 그래픽 모드로 열거나(open) 닫는(close) 함수

  ② 그리기(drawing) 함수

  ③ 속성을 설정하는 함수(set...())

  ④ 속성을 얻는 함수(get...())

  ⑤ 그리기 모드(drawing mode)를 지정하는 함수

  ⑥ 팔레트(palette) 관련 함수

  ⑦ 비트블릿트(bitblt: BIT BLock Transfer)관련 함수

  ⑧ 페이징(paging) 관련 함수

① 그래픽 모드로 열거나 닫는 함수

  initgraph()■는 그래픽 모드로 여는 작업을 한다. 그래픽 모드로 여는 것은

            그래픽 모드를 여는 함수가 opengraph()가 아니고 initgraph()인 것에 주목하
          라. 이것은 BGI의 특정한 초기화 작업과 관계가 있다. 실제로 initgraph()는
          그래픽을 여는(open) 작업보다 초기화(initialization) 작업의 비중이 크다.

IBM PC에만 유일한 기능인데 텍스트 모드(text mode)를 그래픽 모드
(graphic mode)로 바꾸는 작업을 말한다. IBM 호환 PC는 텍스트 모드와 그
래픽 모드를 구분한다. PC는 부팅(booting)시에 텍스트 모드로 시작한다.
DOS는 사용자 인터페이스가 텍스트 환경이지만, Windows는 그래픽 환경이
다. 하지만, Windows도 텍스트 모드에서 부팅을 시작한다.

  모든 그래픽 함수는 그래픽 모드에서만 동작한다.

  closegraph()는 그래픽 모드를 다시 텍스트 모드로 바꾸는 일을 한다. 이것
을 '그래픽 모드를 닫는다.'라고 한다.

② 그리기(drawing) 함수

  line()은 선을 그린다. rectangle()은 사각형을 그린다. ellipse()는 타원을
그린다. 이외에도 많은 함수가 존재한다.

③ 속성을 설정하는 함수(set...())

  setlinestyle()은 선의 모양과 굵기를 설정한다. setcolor()는 그리기 함수
의 선 색(line color)을 설정한다. setfillstyle()은 면(face)의 모양과 색을 설
정한다. 선의 색은 setcolor()로 설정하지만, 면 모양의 색은 setfillstyle()로 설
정하는 것에 주의하라. 이외에도 몇 가지 함수가 존재한다.

④ 속성을 얻는 함수(get...())

  getlinesettings()는 선의 속성을 얻는다. getfillsettings()는 면의 속성을
얻는다. 이외에도 몇 가지 함수가 존재한다.

⑤ 그리기 모드(drawing mode)를 지정하는 함수

  setwritemode()는 선 혹은 비트맵(bitmap)을 그릴 때 배경 그림과의 연산
모드를 설정한다.

⑥ 팔레트(palette) 관련 함수

  setpalette()는 소프트웨어 팔레트를 하드웨어 팔레트에 지정한다.
setrgbpalette()는 특정 색 인덱스(color index)의 RGB를 설정한다. 이 외에
도 몇 가지 함수가 존재한다.

⑦ 비트블릿트(bitblt: BIT BLock Transfer)관련 함
수

  getimage()는 화면의 특정한 영역(실제로는 비디오 램)을 메모리로 복사한
다. putimage()는 메모리의 비트맵을 화면에 전송(transfer)한다. 이 때
setwritemode()에서 지정한 그리기 모드로 전송한다.

⑧ 페이징(paging) 관련 함수

  setactivepage()는 실제로 그리기가 진행될 페이지를 설정한다.
setvisualpage()는 화면에 보여질 페이지를 설정한다(이 두 함수의 자세한 기
능은 '더블 버퍼링(double buffering)'을 참조하라).

  준비 사항

  BGI는 표준 C함수가 아니므로, 사용하기 위해서는 특별한 설정이 필요하다.
예를 들면, Borland C++ 3.x인 경우 Options->Linker->Libraries에서 필요한
설정을 해야 한다.

             Borland C++ 3.x인 경우 BGI를 사용하기 위해서는 Libraries 항목을 선택해
          서 필요한 설정을 한다.

  Libraries를 선택하면 대화상자가 나타나는데 여기서 Graphics Library 체크
박스 항목을 체크해야 한다.

             나타나는 대화 상자에서 Graphics Library항목을 체크한다. 이것은 소스코
          드가 BGI 함수를 선언한 graphics.h 헤더파일을 포함할 수 있도록 한다.

  Borland C++ 4.x이상인 경우는 프로젝트를 만들 때 설정한다. 예를 들면 아
래의 그림은 Borland C++ 4.5의 프로젝트를 만드는 화면인데 Platform으로
DOS(Standard)를 선택하고, Standard Libraries의 BGI 항목을 체크한다.

             Borland C++ 4.0이상에서 도스의 BGI를 이용하기 위해서는 반드시 프로젝
          트를 만들어야 한다. 위의 그림은 Borland C++ 4.5의 Project->New를 선택
          했을 때의 대화상자이다. Platform에서 DOS(Standard)를 선택하고, Standard
          Libraries의 BGI항목을 체크한다.

  선 그리기

  이제 그래픽 화면에 선을 그리는 작업을 해 보자. 먼저 graphics.h 파일을
include해야 한다.

#include <graphics.h>

void main() {

}

  그리고 initgraph()를 이용하여 그래픽 모드를 열어야 하는데, 고전적인 이
유로 이 함수의 사용은 조금 복잡하다.

  표준 비디오 입출력 규격인 VESA 표준이 정해지기 전, 여러 개의 그래픽
어댑터(adapter) - 화면 출력을 제어하기 위해 PC의 슬럿(slot)에 끼우는 하
드웨어 장치 - 가 나름대로의 입출력을 지원하며 존재했다. 그러한 어댑터에
는 최초의 어댑터인 CGA(color graphic adapter)와 우리 나라에서 80년대
후반까지 최고의 인기를 누렸던 흑백 어댑터인 허큘리스(HGA: hercules
graphic adapter)■, IBM의 실패작인 EGA(enhanced graphic adapter)와

             독자들 중 허큘리스에서 동작하는 CGA 에뮬레이터(emulator)인
          simcga.com을 기억하는 독자는 거의 없을 것이다. 필자는 그 당시 IBM의
          CGA 게임을 즐기기 위해 하루에 여러 번 simcga를 실행했다. 실제로
          Hercules가 CGA보다 인기가 있었던 이유는 흑백 어댑터라서 가격이 싼 턱
          도 있었지만, simcga가 거의 완벽하게 CGA를 에뮬레이션했기 때문이기도
          하다.

수퍼 VGA(super VGA)가 등장하기 전 IBM의 마지막 표준인 VGA(video
graphic array)등이 있다.

  문제는 이러한 그래픽 어댑터가 나름대로의 입출력 사양을 가지고 있었기
때문에, line()같은 선을 그리는 함수도 그래픽 어댑터마다 따로 코드를 작성
해야 했다. 그래서 BGI 라이브러리 역시 그래픽 어댑터마다 별도의 라이브러
리를 가지고 있는데, CGA인 경우 CGA.BGI, Hercules인 경우 HERC.BGI,
EGA와 VGA인 경우 EGAVGA.BGI등이 그것이다. 이것은 .OBJ 파일임에도
불구하고 확장자는 .BGI이며 컴파일러가 설치된 폴더의 \BGI 하위 폴더에 외
부 파일로 존재한다. 필자의 경우 BorlandC++ 3.1이 E: 드라이브에 설치되어
있는데, E:\BorlandC\BGI 폴더의 내용은 다음과 같다.

 Volume in drive E is HELP

 Volume Serial Number is 10F4-225A

 Directory of E:\BorlandC\BGI

.              <DIR>        07-13-98  2:18p .

..             <DIR>        07-13-98  2:18p ..

ATT      BGI         6,332  06-10-92  3:10a ATT.BGI

BGIDEMO  C          40,382  06-10-92  3:10a BGIDEMO.C

BGIOBJ   EXE        12,208  06-10-92  3:10a BGIOBJ.EXE

BOLD     CHR        14,670  06-10-92  3:10a BOLD.CHR

CGA      BGI         6,332  06-10-92  3:10a CGA.BGI

EGAVGA   BGI         5,554  06-10-92  3:10a EGAVGA.BGI

EURO     CHR         8,439  06-10-92  3:10a EURO.CHR

GOTH     CHR        18,063  06-10-92  3:10a GOTH.CHR

HERC     BGI         6,204  06-10-92  3:10a HERC.BGI

IBM8514  BGI         6,665  06-10-92  3:10a IBM8514.BGI

LCOM     CHR        12,083  06-10-92  3:10a LCOM.CHR

LITT     CHR         5,131  06-10-92  3:10a LITT.CHR

PC3270   BGI         6,012  06-10-92  3:10a PC3270.BGI

SANS     CHR        13,596  06-10-92  3:10a SANS.CHR

SCRI     CHR        10,987  06-10-92  3:10a SCRI.CHR

SIMP     CHR         8,437  06-10-92  3:10a SIMP.CHR

TRIP     CHR        16,677  06-10-92  3:10a TRIP.CHR

TSCR     CHR        17,355  06-10-92  3:10a TSCR.CHR

EGAVGA   OBJ         5,658  05-22-97 11:11a EGAVGA.OBJ

README   TXT           157  05-22-97 11:11a readme.txt

WORK     TXT             0  10-06-98 11:11p work.txt

        21 file(s)        220,942 bytes

         2 dir(s)      70,541,312 bytes free

             \BGI 폴더의 내용: 각 어댑터에 해당하는 라이브러리 파일이 .BGI 확장자
          를 가지면 존재한다. VGA의 경우 EGAVGA.BGI 파일이 존재한다.

  독자들도 자신의 컴파일러가 설치된 폴더의 \BGI 폴더를 확인해 보기 바란
다. 예를 들어 Turbo C++ 3.0이 C:\Tc에 설치된 경우 C:\Tc\BGI 폴더에
*.BGI 파일이 존재한다. 파일의 리스트를 보면 확장자가 .CHR인 파일이 있는
데, 이것은 BGI가 지원하는 스트로크드 문자 폰트(stroked character font)이
다(벡터 폰트의 일종으로 settextstyle()함수에서 폰트의 모양을 설정하면 지정
한 .CHR 파일이 메모리로 로드된다).

  이제 initgraph()를 설명할 준비가 되었다. initgraph()의 첫 번째 파라미터는
바로 그래픽 어댑터의 종류이다. 예를 들어, VGA인 경우는 9를 지정하고,
Hercules인 경우는 7을 지정한다. 세 번째 파라미터는 .BGI파일이 있는 곳의
경로를 지정한다. 특별하게도 .BGI 파일은 initgraph()에 의해 실행시에 메모
리에 로드된다. 또한 여러 가지 초기화(선의 속성, 면의 속성 등)를 한다. 이것
이 함수의 이름이 opengraph()가 아닌, initgraph()인 이유이다.

  두 번째 파라미터는 특정 그래픽 어댑터의 해상도(resolution)를 설정한다.
뒤에 발표된 그래픽 어댑터는 옛 제품과의 호환을 위해, 여러 가지 해상도를
지원한다. 이것을 그래픽 모드(graphic mode)라 한다. 예를 들어 VGA mode
0은 640×300, 16컬러이며, mode 1은 640×350, 16컬러이며, mode 2는 VGA
최고의 해상도인 640×480, 16컬러이다.

  그러므로 VGA mode 2로 설정하기 위해서는 아래와 같이 코드를 작성한다.

#include <graphics.h>

void main() {

    int gd=9,gm=2;

    initgraph(&gd,&gm,"f:\\bc45\\bgi");

    closegraph();

}

  필자의 경우 위 프로그램은 Borland C++ 4.5를 사용해서 컴파일했는데, BC
4.5의 설치 폴더가 F:\Bc45\이다. 또한 임의의 변수 이름을 사용해도 되지만,
Graphic Driver를 의미하는 gd, Graphic Mode를 의미하는 gm을 사용했다.

  독자들은 gd와 gm의 주소가 전달되는 사실에 주목했는가? 만약 위의 프로
그램이 Hercules에서 실행된다면, initgraph()는 그래픽 모드 열기에 실패한다.
그렇다면 지금 PC에 장착된 어댑터가 어떤 종류의 어댑터인지 어떻게 아는
가? 그렇다. 바로 그 문제를 해결하기 위해서 gd와 gm의 주소를 전달한다.
gd와 gm은 현재 가능한 최상의 그래픽 어댑터와 모드를 각각 리턴한다. 만약
위의 프로그램이 Hercules에서 실행된다면, 그래픽 모드 열기에는 실패할 것
이고, gd는 7로 설정될 것이다.

  initgraph()가 그래픽 어댑터를 발견하는 기능을 가지고 있다면, 특정한 그
래픽 어댑터를 지정하지 않고 스스로 발견하도록 하는 것은 좋은 방법이다.
그렇게 하기 위해서는 gd에 0을 설정한다. gm은 설정할 수가 없는데, 어떤
그래픽 어댑터가 선택될지 모르기 때문이다. 이때 그래픽 모드는 디폴트(최고
의 해상도 모드)로 설정된다. VGA의 디폴트 모드는 2로 640×480, 16컬러, 2
페이지를 지원한다.

#include <graphics.h>

void main() {

    int gd=0,gm;

    initgraph(&gd,&gm,"f:\\bc45\\bgi");

    closegraph();

}

  이제 선을 그릴 준비가 되었다. 화면의 우측 상단에서 시작하여, 좌측 하단
에서 끝나는 대각선을 그려보자.

  먼저, 독자들은 또한 그리기 함수가 사용하는 좌표계(coordinate system)에
대해 알아야 하는데 이것을 화면 좌표계(screen coordinate system)라 한
다. 대부분의 그래픽 라이브러리는 좌표계를 설정하는 함수를 지원하지만,
BGI는 지원하지 않으면 항상 픽셀(pixel: PICture ELement) 단위의 한 가지
좌표계만을 사용한다. 또한 이 좌표계의 원점은 (0,0)이며 항상 좌측 상단(left
top)이다.

             화면 좌표계: 원점은 좌측 상단이며, 가로로 증가하는 x축, 아래로 증가하
          는 y축을 가진다. VGA 표준 해상도의 경우 우측 하단의 끝점의 좌표는
          (639,479)이다.

  소스는 아래와 같다.

#include <graphics.h>

#include <conio.h>

void main() {

    int gd=0,gm;

    initgraph(&gd,&gm,"f:\\bc45\\bgi");

    line(0,0,639,479);

    getch();//화면 출력이 금방 없어지는 것을 막기 위하여 이 함수를 사용

            //하였다.

    closegraph();

}

  3픽셀 폭을 가지는 초록색 실 선(solid line)을 그리기 위해서는
setlinestyle()함수를 사용해서 선의 속성을 설정한다. 한 번 설정된 속성은 다
시 set...()함수를 사용하여 속성을 설정하지 않는 한 바뀌지 않는다.

#include <graphics.h>

#include <conio.h>

void main() {

    int gd=0,gm;

    initgraph(&gd,&gm,"f:\\bc45\\bgi");

    setcolor(GREEN);

    setlinestyle(SOLID_LINE,0,THICK_WIDTH);

    line(0,0,639,479);

    getch();

    closegraph();

}

  결과는 아래와 같다. 위의 소스에서 GREEN은 색 상수, SOLID_LINE은 선
유형 상수, THICK_WIDTH는 선의 굵기를 지정하는 상수이며, graphics.h에
열거형 상수로 설정되어 있다. 도움말 기능을 이용하면, 이 상수 외에 다른 값
들을 살펴볼 수 있다.

             속성을 설정하는 함수와 그리기 함수는 구분되어 있다. 속성을 설정하는
          함수는 이 후의 모든 그래픽 함수에 영향을 미친다.

  실습문제

1.


99. 이 책을 읽는 법

  아래의 글을 우리 교회 전도지에 있던 글인데, 이 책을 다 읽는데 도움이
될 것 같아 옮겨 쓴다. 물론 전도지에서는 이 글을 강조할 의도는 아니고, 전
도를 강조할 의도로 이 글을 삽입하였을 것이다. 하지만, 컴퓨터 관련서적이
전도가 주제가 될 수는 없다. 하지만, 독자들은 본인이 이 장을 별도로 마련한
목적이 '바로 전도'임을 어느 정도 직감하였을 것이다.

행복을 약속하는 성공 8 법칙

사람은 누구나 성공하기를 바랍니다. 그러나 그 성공은 누구에게나 주어지는
것이 아닙니다. 여기 성공할 수 있는 비결 8가지를 소개합니다. 성공하시어 행
복을 누리시기를 바랍니다.

1. 「할 수 있다」는 적극적인 사고방식을 가지십시오.

          당신에게는 무궁무진한 잠재력이 있습니다. 이 힘을 활용하면 어떤 일도 할
수 있습니다. 자신감을 갖고 매사에 능동적으로 임하십시오.

2. 마음의 소원을 당신의 목표로 삼으십시오.

  그러나 당신이 결정한 목표가 마음의 소원과 다르다면 곧 목표를 수정해야
만 됩니다.

3. 부정적인 사고방식을 버리십시오.

  '나는 할 수 없다', '나 같은게 감히'라는 생각은 금물입니다. 그런 생각이 날
때 그것은 과거의 모습이고 지금은 할 수 있다, 나는 새 사람이 되었다고 자
신감으로 임하십시오.

4. 항상 긍정적인 말을 계속 사용하십시오.

  말은 힘과 용기를 더해주는 영양소와 같습니다. '할 수 있다'라는 말을 반복
하십시오.

5. 심는대로 거둔다는 법칙을 기억하십시오.

  진정한 성공은 땀과 수고를 통해서만 완성되는 열매입니다. 아름다운 무지
개는 반드시 비가 온 후 볼 수 있습니다.

6. 어떤 어려움이 와도 낙심하거나 포기하지 마십시오.

  실패는 성공의 어머니입니다. 칠전팔기의 용기와 신념으로 실패를 두려워하
지 마십시오.

7. 큰 꿈과 그 꿈이 이루어진다는 믿음을 가지십시오.

  허황된 꿈이 아니라면 꿈은 크게 가지세요. 그리고 그 꿈이 이루어진다는
믿음을 가지십시오. 사람은 할 수 없으되 하나님(The God)은 할 수 있습니다.

8. 모든 일에 감사하십시오.

  비록 실패해도 감사하여야 됩니다. 그리고 실패를 새로운 기회로 알고 다시
시작하는 정신이 필요합니다.

연습문제

1.